Initial skill library: 9 skills for delegate briefs
WordPress plugin rebrand/conventions/remote-CLI patterns, Gitea release workflow, bastille jail provisioning, remote shell quoting safety, server fleet map, delegate brief writing, and verification discipline -- all derived from real incidents this session, plus two skills adapted (MIT license, attributed) from obra/superpowers and andrej-karpathy-skills.
This commit is contained in:
@@ -1,3 +1,63 @@
|
||||
# agent-skills
|
||||
|
||||
Reusable skill library for AI coding delegates (kimi, agy, vibe, codex, session Claude) -- self-contained process definitions so delegate briefs can reference a skill instead of re-explaining the process every time.
|
||||
Reusable skill library for AI coding delegates (session Claude, kimi, agy,
|
||||
vibe, codex) working on the CloudHost/iWP.es projects. Goal: capture
|
||||
process knowledge once, so a delegate brief can reference a skill instead
|
||||
of re-explaining the same process from scratch every time.
|
||||
|
||||
## Format
|
||||
|
||||
Each skill is a directory under `skills/` containing a `SKILL.md` with
|
||||
YAML frontmatter (`name`, `description`, optional `license`/`source`)
|
||||
followed by the actual instructions in markdown. This is the same format
|
||||
Claude Code's native `Skill` tool consumes directly, and matches the
|
||||
convention used by other cross-platform skill projects (see Provenance
|
||||
below) -- Gemini CLI's `activate_skill` tool and some Codex plugin setups
|
||||
can potentially consume the same format natively, though that hasn't
|
||||
been separately verified for this repo yet.
|
||||
|
||||
**For delegates that don't natively discover skills** (kimi, vibe, and
|
||||
any codex/agy invocation not specifically configured for plugin
|
||||
discovery): read the relevant `SKILL.md` yourself before writing the
|
||||
delegate's brief, and fold its content into the prompt directly rather
|
||||
than assuming the delegate can fetch it itself.
|
||||
|
||||
## Skills in this repo
|
||||
|
||||
- `wordpress-plugin-rebrand` -- forking/rebranding an existing WP plugin
|
||||
under a new brand prefix.
|
||||
- `wordpress-plugin-conventions` -- baseline WP plugin coding standards.
|
||||
- `wordpress-cli-remote-execution` -- running wp-cli against a site inside
|
||||
a remote jail/container.
|
||||
- `gitea-release-workflow` -- tagging releases on self-hosted Gitea,
|
||||
including a real release-asset-unreachable gotcha and its workaround.
|
||||
- `bastille-jail-provisioning` -- FreeBSD jail creation via clone-from-base,
|
||||
including a real recurring IP-conflict gotcha.
|
||||
- `remote-shell-quoting-safety` -- the base64-push pattern for landing
|
||||
`$`/quote-heavy content through nested shell layers without corruption.
|
||||
- `server-fleet-map` -- which host is for what (dev/corporate/affiliate/
|
||||
customers) and how to decide where new work belongs.
|
||||
- `delegate-brief-writing` -- what a self-contained brief for a memoryless
|
||||
CLI delegate needs to contain.
|
||||
- `verification-before-completion` -- never relay a delegate's self-report
|
||||
as fact; how to actually re-verify.
|
||||
- `karpathy-guidelines` -- general LLM-coding behavioral guidelines
|
||||
(simplicity, surgical changes, surfacing assumptions).
|
||||
|
||||
## Provenance
|
||||
|
||||
Some skills here are original (derived directly from real incidents on
|
||||
this project); some are adapted from external open-source skill
|
||||
libraries under their original MIT licenses, with attribution kept in
|
||||
each skill's frontmatter:
|
||||
- [obra/superpowers](https://github.com/obra/superpowers) (MIT)
|
||||
- [SuperClaude-Org/SuperClaude](https://github.com/SuperClaude-Org/SuperClaude_Framework) (MIT)
|
||||
- andrej-karpathy-skills (MIT)
|
||||
|
||||
## Adding a new skill
|
||||
|
||||
When a real, non-obvious pattern or gotcha comes up more than once,
|
||||
write it down here rather than re-discovering it next time. Keep each
|
||||
skill focused on one concern, include the *why* (not just the *what*) so
|
||||
future edge cases can be judged sensibly, and prefer concrete confirmed
|
||||
incidents over generic advice.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: bastille-jail-provisioning
|
||||
description: Use when creating a new FreeBSD bastille jail (a new site/service on a shared-LAN jail host) via clone-from-known-good-base. Covers the IP-conflict gotcha that has caused real, time-costly incidents twice.
|
||||
---
|
||||
|
||||
# Bastille Jail Provisioning
|
||||
|
||||
Pattern for standing up a new jail on a FreeBSD host running Bastille,
|
||||
by cloning a known-working base jail (already has nginx+php-fpm+packages
|
||||
configured) rather than building from scratch.
|
||||
|
||||
## CRITICAL FIRST STEP: arp-scan before picking an IP
|
||||
|
||||
**Confirmed real incident, twice, on two different hosts on the same
|
||||
shared LAN**: an IP address that looked free by every host-local check
|
||||
(`bastille list all`, `ifconfig` aliases, a manually-tracked "already
|
||||
used" list) turned out to already belong to a **different physical
|
||||
device, or a jail on a different host sharing the same LAN segment**.
|
||||
Symptom: the new jail's DNS resolution and outbound TCP connections
|
||||
silently fail or time out with no useful error — this looks exactly like
|
||||
a jail networking/pf config bug and can cost real time chasing the wrong
|
||||
theory before the actual cause (an IP conflict) is found.
|
||||
|
||||
**Before assigning ANY candidate IP, always:**
|
||||
```bash
|
||||
pkg install -y arp-scan # if not already present
|
||||
arp-scan --interface=vtnet0 <candidate-ip>
|
||||
```
|
||||
Zero responses = genuinely free. **Any** response (even from a device
|
||||
with no recognizable vendor match) means it's taken — pick a different
|
||||
IP and re-check. Do not rely on ping (misses devices with ICMP
|
||||
disabled/filtered — a real false-negative that happened once already) or
|
||||
on a per-host "what's already in use" list (each host only knows about
|
||||
its own jails, not other physical devices or other hosts' jails on the
|
||||
same shared subnet).
|
||||
|
||||
If you inherit a jail already provisioned on a conflicting IP: stop the
|
||||
jail, remove the bad alias, update `jail.conf`'s `ip4.addr`, start the
|
||||
jail again (this re-adds the alias on the new IP), then propagate the IP
|
||||
change everywhere else it's referenced (DB user grants scoped by IP,
|
||||
host-level nginx `proxy_pass` target, etc.) — an IP conflict fixed in
|
||||
`jail.conf` alone but not in the DB grant/nginx config leaves the site
|
||||
broken even though the jail itself is now healthy.
|
||||
|
||||
## Provisioning steps (once the IP is confirmed free)
|
||||
|
||||
1. **Clone from a known-good base jail** rather than building from
|
||||
scratch — inherits working nginx/php-fpm/package configuration:
|
||||
```bash
|
||||
bastille clone -a <known-good-base-jail> <new-jail-name> <new-ip>
|
||||
```
|
||||
2. **Wipe cloned content, do a fresh install** if this is a brand-new
|
||||
site (not a migration) — the cloned base's site files are a *template*
|
||||
for the stack config, not content you want to keep.
|
||||
3. **Create the database** on the host's local MariaDB, with the grant
|
||||
scoped to the **jail's own IP**, not the host's IP:
|
||||
```sql
|
||||
CREATE DATABASE `dbname` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'dbuser'@'<jail-ip>' IDENTIFIED BY '<random-password>';
|
||||
GRANT ALL PRIVILEGES ON `dbname`.* TO 'dbuser'@'<jail-ip>';
|
||||
```
|
||||
The application's `DB_HOST` config value is the **host's** own LAN IP
|
||||
(where MariaDB actually listens), NOT the jail's IP — the jail IP is
|
||||
only used for the GRANT's scope. Confirmed gotcha across multiple
|
||||
real migrations: mixing these two up is an easy, non-obvious mistake.
|
||||
4. **Wire the host-level nginx reverse-proxy vhost**, `proxy_pass`ing to
|
||||
the jail's IP.
|
||||
5. **Do NOT touch DNS or the reverse-proxy manager's public routing** as
|
||||
part of automated provisioning — that's a deliberate, manual cutover
|
||||
step for a human to do once the site is verified working over a
|
||||
direct IP/Host-header test. Build and verify fully "dark" first.
|
||||
6. **Smoke test** both directly to the jail IP and via the host nginx
|
||||
proxy (with the right `Host` header and, if the site checks for HTTPS
|
||||
via `X-Forwarded-Proto`, that header too) before considering
|
||||
provisioning done.
|
||||
|
||||
## Persist credentials immediately
|
||||
|
||||
Write the generated DB password (and any other generated secrets) to a
|
||||
durable location the moment they're generated — if a later provisioning
|
||||
step fails, an unrecorded generated password is otherwise lost with no
|
||||
way to recover it short of resetting it.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: delegate-brief-writing
|
||||
description: Use when handing a task off to a CLI-based coding delegate (kimi, agy, vibe, codex, or a fresh subagent) that has no memory of the current conversation. Covers what a self-contained brief needs.
|
||||
---
|
||||
|
||||
# Delegate Brief Writing
|
||||
|
||||
A delegate CLI (kimi, agy, vibe, codex) or a fresh subagent has **zero
|
||||
memory** of anything discussed so far — not the goal, not what's already
|
||||
been ruled out, not which files matter. Everything it needs has to be in
|
||||
the prompt itself.
|
||||
|
||||
## What a self-contained brief needs
|
||||
|
||||
1. **Background**: what is being built and why, in enough detail that
|
||||
the delegate can make small judgment calls correctly without asking.
|
||||
2. **Exact scope boundaries**: what's in scope, and just as important,
|
||||
what's explicitly OUT of scope (a delegate that doesn't know a
|
||||
boundary exists will happily wander past it).
|
||||
3. **Confirmed facts, stated as confirmed** — not "probably" or "should
|
||||
be": if you already checked something (a slug mapping, a config
|
||||
value, a file's actual current content), say so explicitly and tell
|
||||
the delegate not to re-derive it. Re-deriving already-known facts
|
||||
wastes the delegate's budget and can introduce a *different*,
|
||||
incorrect answer.
|
||||
4. **Exact commands/patterns to follow**, not just a description of the
|
||||
goal — if there's an established pattern (see e.g. the
|
||||
`wordpress-plugin-rebrand` skill), give the exact steps, don't make
|
||||
the delegate reinvent the process from a one-line goal statement.
|
||||
5. **Credentials handling**: tell the delegate exactly how to read a
|
||||
credential (e.g. "read the token fresh from `/path/to/token-file`")
|
||||
and explicitly forbid printing/writing it anywhere.
|
||||
6. **What to verify before reporting done**, concretely (lint, a specific
|
||||
grep, a specific functional test) — see the
|
||||
`verification-before-completion` skill.
|
||||
7. **Exact report format expected back**, with a length cap. An
|
||||
unconstrained delegate report is often much longer than useful.
|
||||
|
||||
## Wrapper-agent pattern: make it wait for the real result
|
||||
|
||||
If dispatching via an intermediate "wrapper" agent whose job is to
|
||||
invoke the CLI tool and relay the result: **explicitly instruct it to
|
||||
run the command synchronously and wait for actual completion before
|
||||
ending its turn.** Confirmed real failure mode: a wrapper agent
|
||||
repeatedly backgrounded the CLI process itself and ended its own turn
|
||||
immediately, producing a stream of "still running, will report back"
|
||||
notifications that never actually contained a result — because the
|
||||
wrapper's own turn had already ended, it wasn't actually watching
|
||||
anything. If this happens, don't keep re-prompting the same wrapper —
|
||||
find the actual underlying process directly and monitor it yourself.
|
||||
|
||||
## Splitting work across multiple parallel delegates
|
||||
|
||||
When one deliverable naturally splits into N independent pieces (e.g.
|
||||
"rebrand these 5 plugins"): do ONE piece yourself (or with one delegate)
|
||||
first as a pilot, nail down the exact pattern including any gotchas
|
||||
discovered along the way, THEN write the remaining N-1 briefs
|
||||
incorporating everything learned from the pilot — including the specific
|
||||
mistakes that happened during the pilot and how they were fixed, so the
|
||||
parallel delegates don't repeat them. A brief written *after* the pilot
|
||||
is meaningfully more reliable than N briefs all written from a cold
|
||||
start.
|
||||
|
||||
## Always re-verify independently afterward
|
||||
|
||||
See `verification-before-completion`. A delegate's own final report,
|
||||
however detailed and confident-sounding, is a claim — re-derive the key
|
||||
facts yourself before treating the task as actually done.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: gitea-release-workflow
|
||||
description: Use when tagging a release for a plugin/package on a self-hosted Gitea instance, especially when the release needs to be downloadable by an external system (an auto-updater, a CI job, etc.).
|
||||
---
|
||||
|
||||
# Gitea Release Workflow
|
||||
|
||||
## Creating a release
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "v1.0.0", "name": "v1.0.0", "body": "<changelog>", "draft": false, "prerelease": false}' \
|
||||
"$HOST/api/v1/repos/$OWNER/$REPO/releases"
|
||||
```
|
||||
|
||||
Verify it landed:
|
||||
```bash
|
||||
curl -s -H "Authorization: token $TOKEN" "$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
|
||||
```
|
||||
Note: on at least one real self-hosted instance, `/releases/latest` 404s
|
||||
for unauthenticated requests even on a public repo — always send the
|
||||
token, don't assume public-repo endpoints are auth-free.
|
||||
|
||||
## KNOWN GOTCHA: release-asset downloads can be unreachable
|
||||
|
||||
**Confirmed live on a real self-hosted Gitea instance**: uploading a zip
|
||||
as a release asset and then trying to fetch it via the asset's own
|
||||
`browser_download_url` field returned an **internal address**
|
||||
(`http://localhost:3000/...`) instead of the instance's real public
|
||||
hostname — a `ROOT_URL` misconfiguration server-side. This makes the
|
||||
uploaded asset **completely unreachable from outside the Gitea server
|
||||
itself**, even though the release object and its metadata (version,
|
||||
changelog) are fine.
|
||||
|
||||
**Symptom**: `update-check`-style API calls that only read release
|
||||
metadata work fine; the actual **download** step 404s or times out.
|
||||
|
||||
**Workaround that reliably works**: don't rely on the release-asset
|
||||
upload/download mechanism at all for the actual file bytes. Instead use
|
||||
Gitea's **repo-archive endpoint**, which zips the tagged git tree
|
||||
on-the-fly and is served from the same working API path pattern as
|
||||
everything else:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $TOKEN" -o output.zip \
|
||||
"$HOST/api/v1/repos/$OWNER/$REPO/archive/$TAG.zip"
|
||||
```
|
||||
|
||||
This means: the release object still gets created (for its version/
|
||||
changelog metadata, which downstream consumers do need), but you do
|
||||
**not** need to upload a zip as a release asset at all — the actual
|
||||
distributable content is whatever's in the tagged git tree, fetched via
|
||||
`/archive/{tag}.zip` at request time.
|
||||
|
||||
**Before trusting the asset-download path on a NEW Gitea instance**: test
|
||||
it once (`curl` the `browser_download_url` directly) rather than
|
||||
assuming it works — if it does work on a given instance, the asset-upload
|
||||
approach is simpler and fine to use; the archive-endpoint workaround is
|
||||
only needed on instances that have this specific misconfiguration. Don't
|
||||
apply the workaround blindly without confirming the actual failure mode
|
||||
exists on the instance you're using.
|
||||
|
||||
## Archive-endpoint folder-naming gotcha
|
||||
|
||||
The `/archive/{tag}.zip` endpoint names the zip's top-level folder based
|
||||
on the repo (exact naming varies by Gitea version — sometimes the repo
|
||||
name, sometimes lowercased). If the repo's own files live in a
|
||||
subdirectory rather than at repo root, the resulting zip gets a
|
||||
**double-nested** top-level folder. Keep single-purpose repos (one
|
||||
plugin/package per repo) flat at the root to avoid this — see the
|
||||
`wordpress-plugin-rebrand` skill's flatten-to-root step.
|
||||
|
||||
## Forking into a new org
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"organization": "TargetOrg", "name": "new-repo-name"}' \
|
||||
"$HOST/api/v1/repos/$SOURCE_OWNER/$SOURCE_REPO/forks"
|
||||
```
|
||||
The `name` field renames the fork on creation — you don't have to accept
|
||||
the source repo's own name. Fork inherits the **source repo's full git
|
||||
history**; if you're about to do a wholesale rebrand and don't need that
|
||||
history preserved, a `git init` + single clean commit + `git push --force`
|
||||
is often cleaner than carrying an unrelated-looking history forward — but
|
||||
only force-push a repo that's genuinely fresh with nothing else depending
|
||||
on its prior state.
|
||||
|
||||
## Verify a release actually works end-to-end
|
||||
|
||||
Don't stop at "the release object exists" — actually fetch the archive
|
||||
and inspect it:
|
||||
```bash
|
||||
curl -s -H "Authorization: token $TOKEN" -o /tmp/verify.zip \
|
||||
"$HOST/api/v1/repos/$OWNER/$REPO/archive/$TAG.zip"
|
||||
unzip -l /tmp/verify.zip | head -20
|
||||
```
|
||||
Confirm: single non-double-nested top-level folder, expected files
|
||||
present, no truncation.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: karpathy-guidelines
|
||||
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Karpathy Guidelines
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: remote-shell-quoting-safety
|
||||
description: Use when a command or file content needs to pass through multiple nested shell layers (e.g. local shell -> ssh -> a jail/container exec wrapper -> su -> the target interpreter). Avoids silent corruption of $, quotes, and special characters.
|
||||
---
|
||||
|
||||
# Remote Shell Quoting Safety
|
||||
|
||||
## The problem
|
||||
|
||||
Content containing `$`, quotes, or other shell-special characters gets
|
||||
progressively mangled the more shell layers it passes through (local
|
||||
shell → `ssh` → a container/jail exec wrapper like `bastille cmd` →
|
||||
`su -c` → the actual target interpreter). Each layer's own quoting rules
|
||||
interact, and by 3+ layers deep it becomes extremely hard to reason about
|
||||
correctly — and failures are often **silent**: no error, just corrupted
|
||||
content (e.g. `$_SERVER['KEY']` silently becoming `['KEY']`, `$` and the
|
||||
variable name simply eaten).
|
||||
|
||||
**Confirmed real incident**: a `wp-config.php` snippet containing
|
||||
`$_SERVER['HTTP_X_FORWARDED_PROTO']` passed through
|
||||
`ssh → bastille cmd → su -m www -c '...'` came out the other side as
|
||||
bare `["HTTP_X_FORWARDED_PROTO"]` — no error at the point of corruption,
|
||||
the resulting fatal PHP error only surfaced two steps later at a
|
||||
completely different command, making the actual cause much harder to
|
||||
trace back.
|
||||
|
||||
## The fix: base64, not nested quoting
|
||||
|
||||
Don't try to escape correctly through N layers. Instead:
|
||||
|
||||
1. Write the target file's **complete final content** locally (a real
|
||||
file, not a shell variable).
|
||||
2. Base64-encode it locally: `base64 -w0 localfile > /tmp/payload.b64`
|
||||
(or pipe directly).
|
||||
3. `cat`/pipe the base64 text through the SSH/exec layers (base64 output
|
||||
is alphanumeric-only — nothing for any shell layer to misinterpret,
|
||||
regardless of how many layers it passes through).
|
||||
4. Decode back to the real file **on the far side, in ONE final step**:
|
||||
`base64 -d payload.b64 > /real/target/path`.
|
||||
|
||||
```bash
|
||||
B64=$(base64 -w0 local_file.php)
|
||||
echo "$B64" | ssh user@host "cat > /tmp/upload.b64"
|
||||
ssh user@host "base64 -d /tmp/upload.b64 > /real/target/path && rm /tmp/upload.b64"
|
||||
```
|
||||
|
||||
For a jail/container layer on top of SSH (e.g. `bastille cmd <jail>`),
|
||||
land the base64 payload on the **host** filesystem first (which the jail
|
||||
can usually see via a shared mount or a copy step), then decode from
|
||||
**inside** the jail in one command — don't try to thread the base64
|
||||
string itself through the jail-exec wrapper's own quoting too; one
|
||||
decode step per real filesystem boundary crossed is enough.
|
||||
|
||||
## When to reach for this
|
||||
|
||||
- Any file content containing `$`, backticks, mixed quotes, or PHP/shell
|
||||
code being pushed through 2+ nested shell contexts.
|
||||
- Whenever a "the file exists but does something subtly wrong" bug
|
||||
appears after a multi-hop deployment step, and the content involves
|
||||
special characters — check for quoting corruption before looking
|
||||
elsewhere.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
Don't respond to a quoting failure by adding more layers of escaping
|
||||
(`\\\$`, `'"'"'`, etc.) — this usually makes the problem harder to
|
||||
reason about, not easier, and is exactly the kind of fragile fix that
|
||||
looks like it works until the next slightly-different payload breaks it
|
||||
again. Switch to the base64 pattern instead of debugging escape depth.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: server-fleet-map
|
||||
description: Use when deciding WHICH host a piece of work belongs on, or when you need a host's connection details. Reference map of the FreeBSD/bastille jail fleet and what each host is actually for.
|
||||
---
|
||||
|
||||
# Server Fleet Map
|
||||
|
||||
All hosts reachable over Tailscale (`root@<host>`, SSH key
|
||||
`/root/.ssh/id_ed25519` unless noted). Each host has a distinct
|
||||
**purpose** — check this before assuming a new site/service belongs on
|
||||
whichever host you happen to already be connected to.
|
||||
|
||||
| Host | Tailscale IP | Purpose | Notes |
|
||||
|---|---|---|---|
|
||||
| **staging** | `100.104.61.54` | Dev/staging — pre-production testing, throwaway experiments | New jail IPs here need the same arp-scan discipline as everywhere else on this shared LAN (see `bastille-jail-provisioning` skill) — this is where that gotcha was first found. |
|
||||
| **venus** | `100.109.251.127` (LAN `192.168.0.33`) | Corporate — internal business sites/tools, the iWP.es plugin-subscription platform, and other CloudHost-org first-party projects | Bastille jails, combined web+db (local MariaDB, no shared external DB host). New jails typically created via `bastille clone -a <known-good-base>` rather than from scratch. |
|
||||
| **granja** | `100.98.197.28` | Affiliate network — the travel/tourism site network (menorca.ro, palma.ro, and similar) | No public interface configured directly; sits behind a reverse-proxy manager (NPM) over Tailscale. See `docs/server-granja.md` for the local-curl-simulates-NPM testing trick. |
|
||||
| **funky** | `100.127.21.100` (LAN `192.168.0.218`) | Customers — sites migrated from the old Linux affiliate fleet, actual paying-customer properties (e.g. news.easycut.es, photomouse.ro) | Combined web+db+valkey, bastille jails. Migration target that absorbed several now-decommissioned Linux hosts (maagar, shoe, proton). |
|
||||
| **zamolxis** | `100.115.128.41` | Standalone (non-jailed) FreeBSD site host | Not part of the granja/external-fleet jail families — a single site (Newspaper/tagDiv theme, PHP 8.5) running directly on the host, not in a jail. |
|
||||
| **cabrera** | `100.100.108.19`, SSH port `79` | External Linux affiliate-network host (BTPanel-style) | Part of the older Linux fleet (alongside raptor/maagar/formentor/moonie/spunky/proton/shoe — several since decommissioned/migrated to funky). Hosts easycut.es's main WooCommerce e-commerce site directly (not jailed). |
|
||||
|
||||
## How to decide where new work belongs
|
||||
|
||||
1. **What kind of thing is it?** A corporate/internal tool → venus. A
|
||||
customer's production site → funky. A new affiliate-network travel
|
||||
site → granja. A quick experiment or something not ready for anything
|
||||
resembling production → staging.
|
||||
2. **Does it already have an established host from a related sibling
|
||||
project?** (e.g. another CloudHost-org first-party tool already lives
|
||||
on venus) — match that, don't scatter related things across hosts
|
||||
without a reason.
|
||||
3. **When genuinely unsure, ask** rather than guessing — which host
|
||||
something lives on affects DNS, backup scope, and who has access to
|
||||
it later.
|
||||
|
||||
## Gitea (separate from any of the above)
|
||||
|
||||
`https://devops.cloudhost.es` — not one of the site-hosting fleet, this
|
||||
is the internal git/CI host. Orgs seen so far: `CloudHost` (main
|
||||
internal projects + this skills repo), `iWP.es` (the plugin-subscription
|
||||
product and its forked/rebranded plugins), `InformatiQ` (the security
|
||||
plugin), `External` (mirrors of third-party repos for internal
|
||||
reference).
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: verification-before-completion
|
||||
description: Use before reporting any task as done, especially when the work was done by a delegate/subagent. Never relay a self-report as fact.
|
||||
license: MIT
|
||||
source: adapted from obra/superpowers (MIT license), condensed for delegate-brief reuse
|
||||
---
|
||||
|
||||
# Verification Before Completion
|
||||
|
||||
**A delegate's "done" is a claim, not a fact.** This applies whether the
|
||||
delegate is another AI agent, a subagent, or your own past self in an
|
||||
earlier step. Re-derive the result yourself before reporting completion
|
||||
to whoever is waiting on it.
|
||||
|
||||
## The rule
|
||||
|
||||
Never report "done" based solely on:
|
||||
- A delegate saying it's done.
|
||||
- A test that wasn't actually re-run by you.
|
||||
- Code that "should work" based on reading it, when running it was possible.
|
||||
|
||||
Instead, for each claim, do the cheapest check that would actually catch
|
||||
a wrong claim:
|
||||
|
||||
| Claim | Real check |
|
||||
|---|---|
|
||||
| "The file was created" | `ls`/`cat` it yourself |
|
||||
| "The syntax is valid" | Actually run the linter yourself, don't trust a self-report of having run it |
|
||||
| "The tests pass" | Run them yourself, read the actual output |
|
||||
| "The API returns X" | Make the actual call yourself |
|
||||
| "The deploy succeeded" | Check the live system, not the deploy log's exit code alone |
|
||||
| "The old code is gone" | grep for it yourself |
|
||||
|
||||
## Why this matters (not just process theater)
|
||||
|
||||
Across a real multi-delegate session, every single delegate's first
|
||||
self-report claimed success. Independent re-verification (re-running the
|
||||
linter, re-grepping, and — critically — testing the actual live
|
||||
end-to-end pipeline rather than just checking that files existed) is
|
||||
what actually confirmed correctness. In an earlier phase of the same
|
||||
session, a wrapper-agent pattern (spawn an agent whose only job is to
|
||||
invoke one CLI tool and report back) repeatedly ended its own turn the
|
||||
instant it *started* a background process, before that process had
|
||||
produced any real result — producing a loop of confident-sounding "still
|
||||
running" reports that carried zero information. The fix was to stop
|
||||
trusting the wrapper's self-reported status and directly monitor the
|
||||
actual underlying process.
|
||||
|
||||
## Escalating verification for higher-stakes changes
|
||||
|
||||
- Low-stakes (a text file, a doc change): read it back.
|
||||
- Medium-stakes (application code): lint + a targeted functional test.
|
||||
- High-stakes (something that serves real traffic, handles money, or
|
||||
changes shared/production state): the *actual* end-to-end path a real
|
||||
user would hit — not a unit test in isolation, the real live pipeline.
|
||||
If you cannot exercise the real path yourself (e.g. no browser
|
||||
available for a checkout flow), say so explicitly rather than reporting
|
||||
success on a partial check.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
Don't pad out a report with hedging language as a substitute for actually
|
||||
checking ("this should work", "this is likely correct"). Either verify
|
||||
it, or clearly state it's unverified and why — don't blur the line
|
||||
between the two.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: wordpress-cli-remote-execution
|
||||
description: Use when running wp-cli commands against a WordPress site living inside a remote jail/container (via ssh + a jail-exec wrapper). Covers the output-prefix stripping gotcha and the correct user/path invocation pattern.
|
||||
---
|
||||
|
||||
# WordPress CLI Remote Execution
|
||||
|
||||
## Invocation pattern
|
||||
|
||||
```bash
|
||||
ssh user@host "bastille cmd <jail-name> sh -c 'cd /path/to/site && su -m www -c \"wp <command> --path=/path/to/site\"'"
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Run wp-cli as the **web server user** (`www`, `www-data`, etc.), not
|
||||
root — WordPress file ownership assumptions and some plugin behavior
|
||||
depend on this.
|
||||
- Always pass `--path=` explicitly rather than relying on `cd` alone
|
||||
propagating through every nested layer correctly.
|
||||
|
||||
## GOTCHA: jail-exec wrappers prefix their own output
|
||||
|
||||
A wrapper like `bastille cmd <jail>` prints a `[jailname]:` header line
|
||||
before the actual command output — this is the wrapper's own framing,
|
||||
not part of the real output. Any script that captures this output for
|
||||
further parsing must filter it out:
|
||||
|
||||
```bash
|
||||
OUTPUT=$(ssh user@host "bastille cmd <jail> <command>" 2>&1 | grep -v "^\[<jail>\]:$")
|
||||
```
|
||||
|
||||
Forgetting this is a common, easy-to-miss source of "the command
|
||||
mysteriously failed to parse" bugs when scripting on top of jail-exec
|
||||
wrappers.
|
||||
|
||||
## GOTCHA: `error_reporting(0)` in bootstrap files can mask real errors
|
||||
|
||||
Some CMS/application bootstrap files set `error_reporting(0)` globally
|
||||
near the very top (often to suppress noisy legacy warnings). If you're
|
||||
writing a standalone diagnostic/one-off script that `require`s such a
|
||||
bootstrap file, your own errors after that point are ALSO silently
|
||||
suppressed — a script can fail with **zero output and just a bad exit
|
||||
code**, giving no clue why. Fix: explicitly
|
||||
`error_reporting(E_ALL); ini_set('display_errors', '1');` again
|
||||
**after** requiring the bootstrap, and add a
|
||||
`register_shutdown_function` that dumps `error_get_last()` — this turns
|
||||
a silent failure into an actual, readable error message.
|
||||
|
||||
## GOTCHA: glob patterns fail silently under some remote shells
|
||||
|
||||
`bastille cmd <jail> grep -rln 'pattern' /some/path/*.php` can fail with
|
||||
`zsh: no matches found: ...` if the jail's default shell is zsh and the
|
||||
glob doesn't expand as expected in that invocation context — with no
|
||||
useful indication that this is a shell-globbing issue rather than "no
|
||||
files matched." Fix: drop the glob and grep the directory recursively
|
||||
instead (`grep -rln 'pattern' /some/path/`, no trailing `/*.ext`) — this
|
||||
avoids the shell needing to expand anything at all.
|
||||
|
||||
## When you need to run PHP with full application bootstrap, standalone
|
||||
|
||||
If you need to call an application's own PHP functions/classes outside
|
||||
the normal web request flow (e.g. to directly test a code path), don't
|
||||
guess at which files to `require` — trace the **actual real bootstrap
|
||||
sequence** the application's own front controller uses (e.g. its
|
||||
`index.php`) by reading it, and replicate that exact require order in
|
||||
your standalone script. Skipping steps because "this part probably isn't
|
||||
needed" reliably produces a cascade of "undefined function/class" errors
|
||||
that have to be debugged one require statement at a time — reading the
|
||||
real bootstrap sequence once up front is faster than that cascade.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: wordpress-plugin-conventions
|
||||
description: Use when writing or modifying a WordPress plugin's PHP code. Baseline structural/security conventions to follow without being told each time.
|
||||
---
|
||||
|
||||
# WordPress Plugin Conventions
|
||||
|
||||
Baseline conventions for first-party WordPress plugin code. Follow these
|
||||
without needing to be told in every brief.
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
plugin-slug/
|
||||
plugin-slug.php — main file: header, constants, bootstrap class
|
||||
includes/
|
||||
class-<prefix>-*.php — one class per concern, class-based not procedural
|
||||
admin/ — admin-only UI (settings pages, dashboards)
|
||||
assets/{css,js}/
|
||||
languages/ — .pot/.po/.mo if the plugin is translatable
|
||||
```
|
||||
|
||||
## Main file skeleton
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: ...
|
||||
* Plugin URI: ...
|
||||
* Description: ...
|
||||
* Version: 1.0.0
|
||||
* Author: ...
|
||||
* Author URI: ...
|
||||
* License: GPL v2 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: plugin-slug
|
||||
* Requires PHP: 7.4
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('PREFIX_VERSION', '1.0.0');
|
||||
define('PREFIX_PATH', plugin_dir_path(__FILE__));
|
||||
define('PREFIX_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
require_once PREFIX_PATH . 'includes/class-prefix-thing.php';
|
||||
|
||||
class Prefix_Main {
|
||||
private static $instance = null;
|
||||
public static function instance() {
|
||||
if (null === self::$instance) { self::$instance = new self(); }
|
||||
return self::$instance;
|
||||
}
|
||||
private function __construct() { /* hook registration only */ }
|
||||
public static function activate() { /* create tables/options/dirs */ }
|
||||
public static function deactivate() { /* reverse activate() side effects */ }
|
||||
}
|
||||
register_activation_hook(__FILE__, ['Prefix_Main', 'activate']);
|
||||
register_deactivation_hook(__FILE__, ['Prefix_Main', 'deactivate']);
|
||||
add_action('plugins_loaded', ['Prefix_Main', 'instance']);
|
||||
```
|
||||
|
||||
Every included file starts with `if (!defined('ABSPATH')) exit;` — never
|
||||
`define('ABSPATH', ...) &&` or any variant, exactly the guard-and-exit
|
||||
form, so the file can never be requested directly over HTTP.
|
||||
|
||||
## Every file must pass `php -l` before you consider a task done
|
||||
|
||||
Not optional, not "probably fine" — actually run it, on every changed
|
||||
file, every time. This is the cheapest possible check and catches a
|
||||
meaningful fraction of real mistakes (typos, mismatched braces from a
|
||||
find/replace, etc.) before they ever reach a live site.
|
||||
|
||||
## Security baseline
|
||||
|
||||
- Nonces on every form/AJAX action: `wp_nonce_field()` /
|
||||
`check_admin_referer()` / `check_ajax_referer()`.
|
||||
- Capability checks before any privileged action:
|
||||
`current_user_can('manage_options')` (or the narrowest capability that
|
||||
actually applies — don't default to `manage_options` for things a
|
||||
lower-privileged role should legitimately be able to do).
|
||||
- Escape on output, every time, using the context-correct function:
|
||||
`esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()` — never raw-echo
|
||||
anything that traces back to user input or the database without one of
|
||||
these.
|
||||
- Sanitize on input: `sanitize_text_field()`, `absint()`,
|
||||
`sanitize_email()`, etc. — appropriate to the expected shape of the
|
||||
data, applied at the point the `$_POST`/`$_GET` value is first read.
|
||||
- `$wpdb->prepare()` for every query with a variable in it — no string-
|
||||
interpolated SQL, ever, no exceptions.
|
||||
|
||||
## Database tables
|
||||
|
||||
If a plugin needs its own tables (not just options), use `dbDelta()`:
|
||||
```php
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
$sql = "CREATE TABLE {$wpdb->prefix}prefix_thing (...) {$charset};";
|
||||
dbDelta($sql);
|
||||
```
|
||||
Track a `DB_VERSION` constant + a stored option so future plugin updates
|
||||
can detect and re-run `dbDelta()` for schema changes.
|
||||
|
||||
## PHP 7.4 compatibility
|
||||
|
||||
Unless a specific brief says otherwise, target PHP 7.4+ (a large fraction
|
||||
of real WordPress hosting is still on 7.4). Avoid PHP 8-only syntax
|
||||
(named arguments, enums, readonly properties, `match`). If a codebase
|
||||
needs `str_contains`/`str_starts_with`/`str_ends_with` (PHP 8.0+) on an
|
||||
environment that might run 7.4, guard with `function_exists()`
|
||||
polyfills rather than assuming they exist.
|
||||
|
||||
## Don't build what WordPress core already gives you
|
||||
|
||||
Before writing custom code for: cron scheduling (`wp_schedule_event`),
|
||||
REST endpoints (`register_rest_route`), settings UI
|
||||
(`register_setting`/`add_settings_field`/`woocommerce_admin_fields` if
|
||||
WooCommerce context), object caching (`wp_cache_*` functions), HTTP
|
||||
requests (`wp_remote_get`/`wp_remote_post`, not raw `curl`) — check
|
||||
whether a core API already does it. Reinventing these is both wasted
|
||||
effort and a common source of subtle bugs core already solved correctly.
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: wordpress-plugin-rebrand
|
||||
description: Use when forking and rebranding an existing WordPress plugin under a new name/prefix (e.g. for a white-label or iWP-branded resale product). Covers identifier renaming, repo flattening, updater bundling, and license-field wiring.
|
||||
---
|
||||
|
||||
# WordPress Plugin Rebrand
|
||||
|
||||
Process for taking an existing WordPress plugin and rebranding it under a
|
||||
new name/prefix, without breaking anything or missing a spot. Validated
|
||||
live across 5 real plugins in one session (WooList, WooBC, WooSol,
|
||||
SlickWoo, MetAI → iWP-branded forks), each independently re-verified
|
||||
end-to-end afterward with zero defects found.
|
||||
|
||||
## Inputs you need before starting
|
||||
|
||||
- Source repo (clone URL + credentials).
|
||||
- New brand prefix (e.g. `iWP`), new product slug (e.g. `iwp-woolist`) —
|
||||
**get the exact slug from wherever the catalog/product list already
|
||||
defines it, never invent your own.**
|
||||
- The updater/license-client file to bundle, if the target platform has
|
||||
one (e.g. a shared `IWP_Updater` class), and its exact integration
|
||||
snippet (api_url, plugin_slug, license_option names).
|
||||
|
||||
## Step 1 — Learn the actual existing convention, don't assume
|
||||
|
||||
Read the plugin's main file first. Every plugin has its own prefix
|
||||
scheme — don't assume it matches a previous rebrand. Identify:
|
||||
- Constant prefix (`PLUGINNAME_...`)
|
||||
- Class prefix (`PluginName_...`)
|
||||
- Function prefix (`pluginname_...`)
|
||||
- Text domain string (usually lowercase-hyphenated, e.g. `pluginname`)
|
||||
- Whether the plugin lives at the repo root or in a subdirectory
|
||||
|
||||
## Step 2 — Flatten to repo root
|
||||
|
||||
If the plugin's files live in a subdirectory (not the repo root), move
|
||||
everything up to root. Reason: many git hosts' repo-archive endpoint
|
||||
(`/archive/{tag}.zip`-style) names the zip's top-level folder after the
|
||||
**repo**, so a nested subdirectory inside the repo produces a
|
||||
double-nested folder in the resulting distributable zip
|
||||
(`reponame/plugin-subdir/...` instead of `reponame/...`). Flattening
|
||||
avoids this categorically rather than requiring special-case handling
|
||||
downstream.
|
||||
|
||||
## Step 3 — Rename, in this order
|
||||
|
||||
1. **Text-domain / hyphenated contexts first**: every `oldname-` (CSS
|
||||
classes, script/style handles, asset file paths, admin page slugs) →
|
||||
`newprefix-oldname-`. A safe global find/replace, catches the vast
|
||||
majority of hyphenated identifiers correctly in one pass.
|
||||
2. **Constants**: `OLDNAME_` → `NEWPREFIX_OLDNAME_`.
|
||||
3. **Classes**: `OldName_` → `NewPrefix_OldName_`.
|
||||
4. **Functions / hooks / underscore contexts**: `oldname_` →
|
||||
`newprefix_oldname_`.
|
||||
5. **camelCase-after-underscore contexts** (JS localize-object names are
|
||||
the common case: `oldnameData` used as the 2nd arg to
|
||||
`wp_localize_script()`) → `newprefix_oldnameData`. **Grep the JS files
|
||||
for the exact existing spelling before finalizing — don't guess the
|
||||
casing, the PHP `wp_localize_script()` call must match byte-for-byte
|
||||
or the JS breaks silently at runtime.**
|
||||
6. **Remaining bare prose occurrences of the product name** (page titles,
|
||||
UI labels, doc-comments) → the new display name (e.g. `MetAI` →
|
||||
`iWP MetAI`). Do this LAST, after class renaming, or you'll double-
|
||||
prefix the class name.
|
||||
7. **File/directory renames** to match: `class-oldname-admin.php` →
|
||||
`class-newprefix-oldname-admin.php`, etc.
|
||||
8. Update the plugin header block: `Plugin Name`, `Author`, `Author URI`,
|
||||
`Plugin URI`, `Text Domain` (always hyphenated, never underscored —
|
||||
WordPress text domains are always hyphen-form even when everything
|
||||
else in that context uses underscores).
|
||||
|
||||
## Step 4 — Cross-check enqueue calls against actual filenames
|
||||
|
||||
Every `wp_enqueue_script()`, `wp_enqueue_style()`, `wp_localize_script()`
|
||||
handle/path argument must match the ACTUAL renamed filenames on disk
|
||||
exactly, or the admin UI silently fails to load its JS/CSS with no error
|
||||
message anywhere obvious. Check this explicitly, don't assume the
|
||||
renames were consistent.
|
||||
|
||||
## Step 5 — Bundle the updater / license client (if applicable)
|
||||
|
||||
Copy the shared updater class unchanged into the plugin's `includes/`
|
||||
directory. Wire it in the main file (near the top, after constants):
|
||||
|
||||
```php
|
||||
require_once <PATH_CONSTANT> . 'includes/class-<updater-file>.php';
|
||||
add_action( 'plugins_loaded', function () {
|
||||
new <UpdaterClass>( [
|
||||
'api_url' => '<the licensing server's API base>',
|
||||
'plugin_file' => __FILE__,
|
||||
'plugin_slug' => '<the confirmed catalog slug>',
|
||||
'version' => <VERSION_CONSTANT>,
|
||||
'license_option' => '<newprefix>_<name>_license_key',
|
||||
] );
|
||||
}, 5 );
|
||||
```
|
||||
|
||||
Then add ONE settings field for the license key (option name matching
|
||||
`license_option` above) into wherever the plugin already has an admin
|
||||
settings UI — follow that file's existing code style/pattern for how
|
||||
other fields are registered/rendered, don't introduce a different
|
||||
pattern. If the plugin has no settings UI at all, add a minimal one
|
||||
rather than skipping this step — license-gated updates need somewhere
|
||||
for the customer to enter the key.
|
||||
|
||||
## Step 6 — Verify before pushing
|
||||
|
||||
1. `php -l` every `.php` file. Zero tolerance for syntax errors.
|
||||
2. Case-insensitive grep the whole tree for the bare old name:
|
||||
`grep -rniE '(^|[^a-z_-])oldname' --include='*.php' --include='*.js' --include='*.css' .`
|
||||
— every hit must be part of a correctly-prefixed token
|
||||
(`newprefix-oldname`, `newprefix_oldname`, `NewPrefix_OldName`, or the
|
||||
new display name). Zero unprefixed exceptions, except possibly an
|
||||
original-authorship credit comment if one genuinely exists.
|
||||
3. Sanity-check for double-prefixing bugs (`iWP iWP`, `IWP_IWP`,
|
||||
`iwp-iwp`, `iwp_iwp` or equivalent for your prefix) — a real mistake
|
||||
that happens when step 3.6 runs before 3.3, or runs twice.
|
||||
|
||||
## Step 7 — Ship
|
||||
|
||||
Commit, push (force-push is fine for a freshly-created fork with no
|
||||
other history depending on it — confirm that's actually the case before
|
||||
force-pushing anything else). Tag a release. See the
|
||||
`gitea-release-workflow` skill for the release/verification steps if the
|
||||
git host is Gitea.
|
||||
|
||||
## Common failure modes (all hit at least once during validation)
|
||||
|
||||
- **Forgetting the flatten-to-root step** → double-nested zip.
|
||||
- **JS localize-object name mismatch** (hyphen where an underscore is
|
||||
required for a valid JS identifier) → admin UI JS silently breaks with
|
||||
no visible error.
|
||||
- **Trusting the delegate's self-report instead of re-verifying** — every
|
||||
single delegate run in the validating session initially reported
|
||||
success; independent re-verification (re-running `php -l` yourself, a
|
||||
fresh grep, an actual functional test through the real serving
|
||||
pipeline) is what actually confirms it, not the report.
|
||||
Reference in New Issue
Block a user