fix(commands): stop assuming shell state survives between blocks

Dogfooding `plan` + `implement` against a throwaway config surfaced one root
defect with many arms: the command templates treat consecutive fenced blocks as
one shell. They are not. Every ```bash fence runs as its own Bash call in its own
process, so a variable set in one block is empty in the next, and `$$` is a
different PID (measured: 21710 vs 22109).

The planner agent confirmed the sharpest arm at runtime, reporting that
`Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was documented in
three command files while being functionally dead. A machine sweep found the same
root in 20 places across 9 files, well past the two the written fasit predicted:

  - `$RAW_FLAG` read from non-shell agent prompts (analyze, plan, implement)
  - `$TMPFILE` read across blocks (tokens, manifest, whats-active,
    plugin-health) — each command could not read the file it had just written
  - `$GLOBAL_FLAG` across blocks (fix)
  - `$TODAY` never assigned in any block (campaign), passing
    `--reference-date ""` to a write CLI in six places
  - three `$$` temp paths handed to the Read tool (fix), which expands neither

All now follow the hardened drift.md pattern: a fixed literal path, or a
re-derivation inside each block that needs it.

Also fixed, all confirmed against ground truth rather than inferred:

  - `implement` printed a rollback ID it never captured (the timestamp lived only
    inside a command substitution) — the one message a user reads after a bad run
  - `plan` reported "No analysis results found" for valid sessions, because Read
    was pointed at a glob it cannot expand; now uses Glob and verifies the
    analysis report exists before spawning the agent
  - five phase commands wrote state.yaml with two of four required fields; since
    the agent writes all four, a follow-up write silently deleted the rest
  - `implement` promised rollback deletes created files; rollback deliberately
    leaves them (M-BUG-26 still open) — the doc, not the engine, was wrong
  - `implement` claimed a score delta with no pre-change measurement
  - `verifier-agent` was told to write a report it has no tool to write
  - dead `Task` tool name in always-loaded rule context; planner-agent template
    demonstrated the inline file content its own line 110 forbids

The sweeps land as tests/commands/command-shell-state-shape.test.mjs, verified
red before the fix and proven able to fail by reintroducing the defect. Two
existing tests asserted the old bash-block mechanism rather than the intent and
were updated. Suite 1449/0; frozen v5.0.0 snapshots and all scanner code
untouched.

Not fixed, deliberately: neither command scope-gates its actions to the audit
target. The generated plan included an edit to a real file under ~/.claude,
outside the throwaway target, because the skill/agent scanners are machine-wide.
That is a design change, not a side fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195udHgCcFegzm7ecKku2Yc
This commit is contained in:
Kjell Tore Guttormsen 2026-08-01 20:12:17 +02:00
commit 09f817977c
17 changed files with 413 additions and 71 deletions

View file

@ -12,7 +12,7 @@ All command files MUST include:
--- ---
name: plugin:command name: plugin:command
description: Short description of what this command does description: Short description of what this command does
allowed-tools: Read, Write, Bash, Task allowed-tools: Read, Write, Bash, Agent
model: sonnet model: sonnet
--- ---
``` ```

View file

@ -8,6 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- **`M-BUG-40` — command templates assumed shell state survives between fenced blocks.** It does not:
every ```` ```bash ```` fence is executed as its own Bash call, in its own process. A variable
assigned in one block is empty in the next, and `$$` (the PID) differs between calls, so a
`/tmp/foo-$$.json` path created in one block can never be reconstructed in a later one. The defect
was surfaced by dogfooding `plan` + `implement`, and **confirmed at runtime by the planner agent
itself**, which reported that `Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was
documented in both files while being functionally dead. A machine sweep found the same root in
**20 places across 9 files**, far past the two predicted: `$RAW_FLAG` referenced from non-shell
agent prompts (`analyze`, `plan`, `implement`); `$TMPFILE` referenced across blocks in `tokens`,
`manifest`, `whats-active` and `plugin-health`, so each command could not read the file it had just
written; `$GLOBAL_FLAG` in `fix`; `$TODAY` in `campaign`, which was **never assigned in any block**
and passed `--reference-date ""` to a write CLI; and three `$$` temp paths handed to the Read tool
in `fix`, which expands neither `$$` nor variables. All now follow the hardened `drift.md` pattern:
a fixed literal path, or a re-derivation inside each block that needs it.
- **`implement` handed out a rollback ID it never captured.** The backup directory was created with
`mkdir -p .../$(date +%Y%m%d_%H%M%S)/`, so the timestamp existed only inside a command
substitution, while step 6 promised `/config-audit rollback {timestamp}` — the one message a user
reads after a bad run. The step now prints `BACKUP_ID` and substitutes it literally.
- **`plan` reported "No analysis results found" for valid sessions.** Step 1 pointed the Read tool at
`~/.claude/config-audit/sessions/*/state.yaml`; Read takes one literal path and does not expand
`*`, so the lookup failed and the command reported the session as missing. It now uses Glob, and
additionally verifies `analysis-report.md` exists before spawning the planner agent — a session can
carry a valid `state.yaml` and still be missing its report.
- **Phase commands wrote `state.yaml` with two of the four required fields.** `.claude/rules/state-management.md`
mandates `current_phase`, `completed_phases`, `next_phase` and `updated_at`; `analyze`, `discover`,
`implement`, `interview` and `plan` named only a subset. Because the planner agent writes all four,
a follow-up full-file Write naming two **deletes** the other two — the fields that make an
interrupted run resumable.
- **`implement` documented a rollback semantics that does not exist.** Its "## Rollback" section
promised to "delete newly created files", while `rollback.md` deliberately leaves them in place and
lists them under "Left in place" (deletion is unimplemented; `M-BUG-26` remains open). The doc now
mirrors actual behaviour rather than describing a half-restore as clean.
- **`implement` claimed a score delta with no source**, since nothing captured the pre-change grade
before the edits ran, and its implied posture call omitted both `--output-file` and `2>/dev/null`
required by the output rules. It now reports a delta only when a pre-change grade was actually
measured.
- **`verifier-agent` was instructed to write a report it has no tool to write** (`tools: Read, Glob,
Grep`, and "Read-only validation" by design). It now returns findings as its final message and the
command appends them with Bash `>>`, preserving both the read-only design and the shared-log
append discipline.
- **Dead tool name in always-loaded context:** `.claude/rules/command-development.md` taught
`allowed-tools: ... Task` while every command uses `Agent`, and `interview.md` carried two more
`Task` references. `planner-agent.md` also contradicted itself — line 110 forbids inline file
content while its own output template demonstrated exactly that, pushing plans past the 200-line
budget the same file sets.
### Fixed (previously released work)
- **`M-BUG-39` — every scanner CLI could truncate its own output when piped.** `process.exit()` - **`M-BUG-39` — every scanner CLI could truncate its own output when piped.** `process.exit()`
terminates immediately, but Node writes stdout **asynchronously** when stdout is a pipe, so whatever terminates immediately, but Node writes stdout **asynchronously** when stdout is a pipe, so whatever
is still buffered is discarded. `scan-orchestrator.mjs` measured **246 854 bytes to a file vs is still buffered is discarded. `scan-orchestrator.mjs` measured **246 854 bytes to a file vs

View file

@ -171,18 +171,10 @@ Total backup size: ~6.4 KB
**Rationale**: **Rationale**:
Code style rules found in 3 projects are identical. Moving to global reduces duplication. Code style rules found in 3 projects are identical. Moving to global reduces duplication.
**Content**: **Content outline** (describe it — do not inline the file):
```markdown Language preferences, then commit format. The implementer reads the source
# Code Style Rules files and writes the content itself; a full file body pasted here is what the
200-line budget above forbids.
## Language Preferences
- TypeScript > JavaScript
- Explicit > implicit
- Lesbarhet > cleverness
## Commit Format
- Conventional Commits: `type(scope): description`
```
**Validation**: **Validation**:
- File exists after creation - File exists after creation

View file

@ -44,17 +44,16 @@ This includes hierarchy mapping, conflict detection, and prioritized recommendat
Tell the user: **"Generating analysis (this takes about 30 seconds)..."** Tell the user: **"Generating analysis (this takes about 30 seconds)..."**
```bash Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
RAW_FLAG="" prompt below is **not** a shell, so a variable assigned in a bash block cannot be
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
```
``` ```
Agent(subagent_type: "config-audit:analyzer-agent") Agent(subagent_type: "config-audit:analyzer-agent")
model: sonnet model: sonnet
prompt: | prompt: |
Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/ Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks) Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Generate comprehensive report covering: Generate comprehensive report covering:
1. Executive summary with key metrics, grouped by userImpactCategory 1. Executive summary with key metrics, grouped by userImpactCategory
2. Hierarchy map visualization 2. Hierarchy map visualization
@ -102,4 +101,4 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
### Step 6: Update state ### Step 6: Update state
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`. Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires: `current_phase: "analyze"`, `completed_phases` (append `analyze` to the existing array — read it first), `next_phase: "plan"`, and `updated_at`. A write that names only two of the four silently deletes the other two.

View file

@ -147,6 +147,9 @@ If already initialized, say so and stop (no clobber). Otherwise tell the user wh
then create it: then create it:
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
@ -170,6 +173,9 @@ at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
them in one call (idempotent — already-tracked repos are skipped, not reset): them in one call (idempotent — already-tracked repos are skipped, not reset):
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
@ -194,6 +200,9 @@ roll-up stays meaningful. Two honest sources, in order of preference:
On approval: On approval:
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \ [--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \
@ -215,6 +224,9 @@ replaces, never accumulates), and **skips — never aborts on** — any repo tha
the user it will read each tracked repo's live config (a few seconds per repo), then on approval: the user it will read each tracked repo's live config (a few seconds per repo), then on approval:
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs refresh-tokens \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs refresh-tokens \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
@ -235,6 +247,9 @@ not buried in a session dir. This step copies it there, byte-faithfully.
that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`: that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`:
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $? --output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
@ -255,6 +270,9 @@ no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json`
**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file): **On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file):
```bash ```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" --write \ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" --write \
--reference-date "$TODAY" \ --reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $? --output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?

View file

@ -84,7 +84,7 @@ Check exit code: 0/1/2 → normal. 3 → "Discovery encountered an error. Try a
### Step 6: Save scope and state ### Step 6: Save scope and state
Write `scope.yaml` and `state.yaml` to session directory. Update state with `current_phase: "discover"`, `next_phase: "analyze"`. Write `scope.yaml` and `state.yaml` to session directory. Update state with all four fields `.claude/rules/state-management.md` requires: `current_phase: "discover"`, `completed_phases: [discover]`, `next_phase: "analyze"`, and `updated_at`. The last two are what make an interrupted run resumable.
### Step 7: Present summary ### Step 7: Present summary

View file

@ -43,7 +43,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# A placeholder in square brackets does not start with a dash, so the arg loop # A placeholder in square brackets does not start with a dash, so the arg loop
# would take it as the scan/fix TARGET instead of a flag. # would take it as the scan/fix TARGET instead of a flag.
GLOBAL_FLAG="" GLOBAL_FLAG=""
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json $GLOBAL_FLAG $RAW_FLAG 2>/dev/null; echo $? node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan.json $GLOBAL_FLAG $RAW_FLAG 2>/dev/null; echo $?
``` ```
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration." Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
@ -53,12 +53,15 @@ Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check
Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above: Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan-$$.json 2>/dev/null; echo $? # Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG="" # --global when the user asked for global scope
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan.json 2>/dev/null; echo $?
``` ```
Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored. Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.
Read `/tmp/config-audit-fix-plan-$$.json` using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan-$$.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping. Read `/tmp/config-audit-fix-plan.json` using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
### Step 3: Present fix plan ### Step 3: Present fix plan
@ -104,10 +107,13 @@ AskUserQuestion:
If confirmed, apply: If confirmed, apply:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply $GLOBAL_FLAG --output-file /tmp/config-audit-fix-applied-$$.json 2>/dev/null; echo $? # Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG="" # --global when the user asked for global scope
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply $GLOBAL_FLAG --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?
``` ```
Read `/tmp/config-audit-fix-applied-$$.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix. Read `/tmp/config-audit-fix-applied.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix.
### Step 6: Show results ### Step 6: Show results

View file

@ -22,12 +22,13 @@ Execute the action plan with full backup, verification, and rollback support.
### Step 1: Parse flags, load and verify ### Step 1: Parse flags, load and verify
```bash Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
RAW_FLAG="" prompt in Step 4 is **not** a shell, so a variable assigned in a bash block cannot
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi be referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
```
Find the most recent session with a plan. If none: "No action plan found. Run `/config-audit plan` first." Find the most recent session with a plan (use the **Glob tool** for
`~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match — Read
does not expand `*`). If none: "No action plan found. Run `/config-audit plan` first."
Use the Read tool on the action plan and count actions. Tell the user: Use the Read tool on the action plan and count actions. Tell the user:
@ -51,12 +52,21 @@ AskUserQuestion:
### Step 3: Create backup ### Step 3: Create backup
Create backup silently: Create backup silently, and **print the backup ID** — Step 6 has to tell the user
how to roll back, and a timestamp that only ever existed inside a command
substitution cannot be quoted later. Shell state does not survive to the next
block, so capture the printed value and substitute it literally from here on:
```bash ```bash
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null BACKUP_ID=$(date +%Y%m%d_%H%M%S)
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
echo "$BACKUP_ID"
``` ```
Use the printed ID wherever `{backup-id}` appears below. Never invent or re-derive
it with a second `date` call — a run that straddles a second boundary would hand
the user a rollback ID that does not exist.
Copy each file to be modified. Generate `manifest.yaml` with checksums. Copy each file to be modified. Generate `manifest.yaml` with checksums.
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists: The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
@ -86,7 +96,7 @@ Agent(subagent_type: "config-audit:implementer-agent")
prompt: | prompt: |
Execute action: {action-id} Execute action: {action-id}
File: {file-path}, Type: {create|modify|delete} File: {file-path}, Type: {create|modify|delete}
Mode: $RAW_FLAG (empty = humanized progress prose; "--raw" = v5.0.0 verbatim) Mode: {mode} ("humanized" = humanized progress prose; "--raw" = v5.0.0 verbatim)
Details: {changes} Details: {changes}
Verify backup exists, make change, validate syntax. Verify backup exists, make change, validate syntax.
When logging progress, use the humanized title/userActionLanguage When logging progress, use the humanized title/userActionLanguage
@ -117,7 +127,19 @@ Agent(subagent_type: "config-audit:verifier-agent")
1. Modified files exist and are syntactically valid 1. Modified files exist and are syntactically valid
2. New files created correctly 2. New files created correctly
3. No new conflicts introduced 3. No new conflicts introduced
Report to: ~/.claude/config-audit/sessions/{session-id}/implementation-log.md Return your findings as your final message. Do NOT write them to a file —
this agent is read-only by design (tools: Read, Glob, Grep) and has no
write tool; instructing it to write a report is a contract it cannot keep.
```
Append the verifier's returned findings to the log yourself, with Bash `>>`
(heredoc) — never the Write tool, for the same reason as Step 4:
```bash
cat >> ~/.claude/config-audit/sessions/{session-id}/implementation-log.md <<'EOF'
## Verification
{verifier findings}
EOF
``` ```
If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback. If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback.
@ -129,27 +151,49 @@ If verifier finds issues: one retry with implementer agent. If still failing: re
**{succeeded} succeeded** | {failed} failed | {skipped} skipped **{succeeded} succeeded** | {failed} failed | {skipped} skipped
{If score improved, run quick posture and show:}
Score impact: {old_grade} → {new_grade} (+{delta} points)
{If failed > 0:} {If failed > 0:}
{failed} action(s) couldn't be completed — see log for details. {failed} action(s) couldn't be completed — see log for details.
**Backup location:** `~/.claude/config-audit/backups/{timestamp}/` **Backup location:** `~/.claude/config-audit/backups/{backup-id}/`
**Rollback:** `/config-audit rollback {timestamp}` **Rollback:** `/config-audit rollback {backup-id}`
**Full log:** `~/.claude/config-audit/sessions/{session-id}/implementation-log.md` **Full log:** `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
``` ```
**On reporting a score.** Only quote a grade *change* if the pre-change grade was
actually captured before Step 4 ran. Once the files are edited, only the new grade
is measurable — a delta computed after the fact has no source and must not be
invented. To offer one, measure first in Step 1 and again here:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file /tmp/config-audit-implement-posture.json 2>/dev/null; echo $?
```
Then Read `/tmp/config-audit-implement-posture.json`. Both the `--output-file` and
the `2>/dev/null` are required by the output rules — a bare scanner call would put
diagnostic output in front of the user. If no pre-change grade was captured, report
the new grade alone and say nothing about a delta.
### Step 7: Update state ### Step 7: Update state
Update `state.yaml` with `current_phase: "implement"`, `next_phase: null`. Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires:
- `current_phase: "implement"`
- `completed_phases`: append `implement` to the existing array (read it first; never replace it)
- `next_phase: null`
- `updated_at`: current timestamp
A full-file Write that names only two of the four silently deletes the other two.
## Rollback ## Rollback
If the user requests rollback at any point: If the user requests rollback at any point:
1. Read `manifest.yaml` from backup 1. Read `manifest.yaml` from backup
2. Restore each file and verify checksums 2. Restore each file and verify checksums
3. Delete newly created files 3. **Report — do not delete — the files this run created.** Rollback restores from
backup, and no backup can exist for a file that did not exist before. Those
paths stay on disk; `/config-audit rollback` lists them under "Left in place"
so the user can remove them deliberately. Promising deletion here would leave a
half-restored config that reads as a clean rollback.
4. Update state to `rolled_back` 4. Update state to `rolled_back`
## Error Handling ## Error Handling

View file

@ -11,8 +11,8 @@ Gather user preferences to inform the action plan.
## IMPORTANT: Inline Execution Only ## IMPORTANT: Inline Execution Only
This command runs AskUserQuestion **directly in the main context** — NOT via a Task subagent. This command runs AskUserQuestion **directly in the main context** — NOT via an `Agent` subagent.
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to a Task subagent. AskUserQuestion requires synchronous terminal interaction and does not work when delegated to an `Agent` subagent.
## Prerequisites ## Prerequisites
@ -44,7 +44,7 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
`/config-audit discover` first" and exit. Read the session's `state.yaml` and `/config-audit discover` first" and exit. Read the session's `state.yaml` and
verify `completed_phases` contains `analyze`; if it doesn't, tell the user verify `completed_phases` contains `analyze`; if it doesn't, tell the user
analysis hasn't run yet and exit. Then read the analysis report for context. analysis hasn't run yet and exit. Then read the analysis report for context.
2. **Conduct interview inline**: Use AskUserQuestion tool directly (NOT via Task). Adapt questions based on analysis findings. 2. **Conduct interview inline**: Use AskUserQuestion tool directly (never delegate it to a subagent via `Agent` — a subagent cannot hold the interactive turn). Adapt questions based on analysis findings.
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md` 3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
4. **Update state** (see state-management rule), with one bound specific to this 4. **Update state** (see state-management rule), with one bound specific to this
command: interview is optional and can be run against a session that already command: interview is optional and can be run against a session that already
@ -53,7 +53,8 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
phase already in `completed_phases` — append `interview` only if it is absent, phase already in `completed_phases` — append `interview` only if it is absent,
leave `current_phase`/`next_phase` pointing at the furthest phase reached, and leave `current_phase`/`next_phase` pointing at the furthest phase reached, and
tell the user the preferences will apply the next time `/config-audit plan` tell the user the preferences will apply the next time `/config-audit plan`
runs. Rewinding a finished session is how its progress gets lost. runs. Rewinding a finished session is how its progress gets lost. Always set
`updated_at` to the current timestamp, whichever branch above applies.
5. **Output summary** 5. **Output summary**
## Interview Questions ## Interview Questions

View file

@ -39,10 +39,9 @@ First non-flag argument is the path (default `.`). Recognized flags:
Tell the user: **"Building token-source manifest for `<path>`..."** Tell the user: **"Building token-source manifest for `<path>`..."**
```bash ```bash
TMPFILE="/tmp/ca-manifest-$$.json"
RAW_FLAG="" RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $? node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file /tmp/config-audit-manifest.json $RAW_FLAG 2>/dev/null; echo $?
``` ```
**Exit code handling:** **Exit code handling:**
@ -52,14 +51,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE"
### Step 3: If `--json` was requested, cat the file and stop ### Step 3: If `--json` was requested, cat the file and stop
```bash ```bash
cat "$TMPFILE" cat /tmp/config-audit-manifest.json
``` ```
Do NOT render the table in JSON mode. Do NOT render the table in JSON mode.
### Step 4: Read JSON and render ### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter): Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
```markdown ```markdown
**Token-source manifest for `<repoPath>`** — ~{total} tokens total **Token-source manifest for `<repoPath>`** — ~{total} tokens total

View file

@ -22,7 +22,11 @@ Generate a prioritized action plan based on analysis results.
### Step 1: Verify session state ### Step 1: Verify session state
Find the most recent session with analysis completed using the Read tool on `~/.claude/config-audit/sessions/*/state.yaml`. If none found: "No analysis results found. Run `/config-audit` first to scan your configuration." Find the most recent session with analysis completed using the **Glob tool** on `~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match. The Read tool takes one literal path and does not expand `*` — pointing it at the glob makes this step report "no analysis results" even when a valid session exists.
If no session is found: "No analysis results found. Run `/config-audit` first to scan your configuration."
Then confirm the report itself exists — a session can carry a valid `state.yaml` and still be missing its report. Read `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`. If it is absent: "Session {session-id} has no analysis report. Run `/config-audit analyze` to generate it." Stop — the planner agent has nothing to read.
### Step 2: Tell the user what's happening ### Step 2: Tell the user what's happening
@ -35,10 +39,10 @@ Actions are ordered by impact, with risk assessment and dependency tracking.
### Step 3: Parse flags and spawn planner agent ### Step 3: Parse flags and spawn planner agent
```bash Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
RAW_FLAG="" prompt below is **not** a shell, so a variable assigned in a bash block cannot be
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi referenced from it. Substitute `{mode}` literally with `--raw` or with `humanized`
``` when writing the prompt.
Tell the user: **"Generating your action plan (this takes about 30 seconds)..."** Tell the user: **"Generating your action plan (this takes about 30 seconds)..."**
@ -49,7 +53,7 @@ Agent(subagent_type: "config-audit:planner-agent")
Generate action plan based on: Generate action plan based on:
- Analysis: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md - Analysis: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
- Interview: ~/.claude/config-audit/sessions/{session-id}/interview.md (if exists) - Interview: ~/.claude/config-audit/sessions/{session-id}/interview.md (if exists)
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks) Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Create a prioritized plan that consumes the humanized finding fields: Create a prioritized plan that consumes the humanized finding fields:
- Group actions by userImpactCategory (e.g., "Configuration mistake", - Group actions by userImpactCategory (e.g., "Configuration mistake",
"Conflict", "Wasted tokens", "Missed opportunity", "Dead config") "Conflict", "Wasted tokens", "Missed opportunity", "Dead config")
@ -94,7 +98,14 @@ You can edit the plan file to remove, reorder, or modify actions before implemen
### Step 5: Update state ### Step 5: Update state
Update `state.yaml` with `current_phase: "plan"`, `next_phase: "implement"`. Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires — a partial write drops the fields that make an interrupted run resumable:
- `current_phase: "plan"`
- `completed_phases`: append `plan` to the existing array (read it first; never overwrite it with a fresh list)
- `next_phase: "implement"`
- `updated_at`: current timestamp
The planner agent may already have written these. Read the file before writing and preserve whichever fields it set — a full-file Write that names only two fields silently deletes the other two.
## Plan Modification ## Plan Modification

View file

@ -35,13 +35,12 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead. Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead.
```bash ```bash
TMPFILE="/tmp/config-audit-plugin-health-$$.json"
RAW_FLAG="" RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $? node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> --output-file /tmp/config-audit-plugin-health.json $RAW_FLAG 2>/dev/null; echo $?
``` ```
Read `$TMPFILE` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error. Read `/tmp/config-audit-plugin-health.json` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error.
The payload carries three things the report needs: The payload carries three things the report needs:

View file

@ -40,7 +40,6 @@ Tell the user: **"Analysing token hotspots for `<path>`..."**
Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` in addition to the v5.0.0 fields. Pass `--raw` through verbatim if the user requested it. Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` in addition to the v5.0.0 fields. Pass `--raw` through verbatim if the user requested it.
```bash ```bash
TMPFILE="/tmp/config-audit-tokens-$$.json"
RAW_FLAG="" RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# Set each to the flag itself when the user asked for it, otherwise leave empty. # Set each to the flag itself when the user asked for it, otherwise leave empty.
@ -48,7 +47,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# loop would take it as the TARGET PATH instead of a flag. # loop would take it as the TARGET PATH instead of a flag.
GLOBAL_FLAG="" # --global GLOBAL_FLAG="" # --global
CACHE_FLAG="" # --no-exclude-cache CACHE_FLAG="" # --no-exclude-cache
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" $GLOBAL_FLAG $CACHE_FLAG $RAW_FLAG 2>/dev/null; echo $? node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file /tmp/config-audit-tokens.json $GLOBAL_FLAG $CACHE_FLAG $RAW_FLAG 2>/dev/null; echo $?
``` ```
**Exit code handling:** **Exit code handling:**
@ -58,14 +57,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file
### Step 3: If `--json` was requested, cat the file and stop ### Step 3: If `--json` was requested, cat the file and stop
```bash ```bash
cat "$TMPFILE" cat /tmp/config-audit-tokens.json
``` ```
Do NOT render tables in JSON mode. Do NOT render tables in JSON mode.
### Step 4: Read JSON and render ### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract: Use the Read tool on `/tmp/config-audit-tokens.json`. Extract:
- `total_estimated_tokens` — top-line number - `total_estimated_tokens` — top-line number
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`) - `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
@ -114,7 +113,7 @@ _Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±20
### Step 5: Cleanup and next steps ### Step 5: Cleanup and next steps
```bash ```bash
rm -f "$TMPFILE" rm -f /tmp/config-audit-tokens.json
``` ```
```markdown ```markdown

View file

@ -33,7 +33,6 @@ Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. D
Tell the user: **"Reading active configuration for `<path>`..."** Tell the user: **"Reading active configuration for `<path>`..."**
```bash ```bash
TMPFILE="/tmp/ca-whats-active-$$.json"
RAW_FLAG="" RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# Set each to the flag itself when the user asked for it, otherwise leave empty. # Set each to the flag itself when the user asked for it, otherwise leave empty.
@ -41,7 +40,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# arg loop would take it as the TARGET PATH instead of a flag. # arg loop would take it as the TARGET PATH instead of a flag.
VERBOSE_FLAG="" # --verbose VERBOSE_FLAG="" # --verbose
SUGGEST_FLAG="" # --suggest-disables SUGGEST_FLAG="" # --suggest-disables
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG 2>/dev/null; echo $? node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file /tmp/config-audit-whats-active.json $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG 2>/dev/null; echo $?
``` ```
**Exit code handling:** **Exit code handling:**
@ -51,14 +50,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPF
### Step 3: If `--json` was requested, cat the file and stop ### Step 3: If `--json` was requested, cat the file and stop
```bash ```bash
cat "$TMPFILE" cat /tmp/config-audit-whats-active.json
``` ```
Do NOT render tables in JSON mode. Do NOT render tables in JSON mode.
### Step 4: Read JSON and render ### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract: Use the Read tool on `/tmp/config-audit-whats-active.json`. Extract:
- `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey` - `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey`
- `totals.estimatedTokens.grandTotal` (and subtotals) - `totals.estimatedTokens.grandTotal` (and subtotals)
@ -154,7 +153,7 @@ Do NOT suggest items you can't name concrete redundancy for. If you can't find 3
### Step 7: Cleanup and next steps ### Step 7: Cleanup and next steps
```bash ```bash
rm -f "$TMPFILE" rm -f /tmp/config-audit-whats-active.json
``` ```
```markdown ```markdown

View file

@ -41,13 +41,33 @@ async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8'); return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
} }
test('Action: every file contains a Bash invocation block', async () => { // plan.md invokes no scanner — it spawns the planner agent. Its only bash
// block used to be a `RAW_FLAG=` assignment referenced from the agent prompt
// below it; a prompt is not a shell, so the agent received the literal string
// `$RAW_FLAG` (confirmed at runtime by the planner agent, session #49).
// Removing that block is the fix, so this assertion skips it.
const AGENT_DRIVEN = new Set(['plan.md']);
test('Action: every scanner-invoking file contains a Bash invocation block', async () => {
for (const name of ACTION_FILES) { for (const name of ACTION_FILES) {
if (AGENT_DRIVEN.has(name)) continue;
const content = await readCommand(name); const content = await readCommand(name);
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`); assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
} }
}); });
test('Action: agent-driven files spawn an Agent instead of a scanner', async () => {
for (const name of AGENT_DRIVEN) {
const content = await readCommand(name);
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
assert.doesNotMatch(
content,
/RAW_FLAG=/,
`${name} must not assign a shell variable it then references from an agent prompt`,
);
}
});
test('Action: every file references the Read tool', async () => { test('Action: every file references the Read tool', async () => {
for (const name of ACTION_FILES) { for (const name of ACTION_FILES) {
const content = await readCommand(name); const content = await readCommand(name);

View file

@ -0,0 +1,188 @@
/**
* Session #49 command-template shell-state shape tests.
*
* Dogfooding the `plan` + `implement` chunk surfaced one root defect with
* several arms: **command templates assume shell state survives between
* fenced blocks.** It does not. Every ```bash fence is executed as its own
* Bash tool call, in its own process:
*
* - A variable assigned in block N is empty in block N+1.
* - `$$` (the PID) differs between calls, so a `/tmp/foo-$$.json` path
* created in one block can never be reconstructed in a later one.
* - The Read tool expands neither shell variables nor `$$` nor globs; it
* takes one literal path.
*
* Measured arms at the time of writing (all fixed by the accompanying commit):
* - `$RAW_FLAG` referenced inside a (non-bash) agent-prompt fence in
* analyze.md, plan.md, implement.md the agent received the literal
* string `$RAW_FLAG`, confirmed at runtime by the planner-agent itself.
* - `$TMPFILE` referenced across blocks in manifest.md, tokens.md,
* whats-active.md, plugin-health.md.
* - `$GLOBAL_FLAG` across blocks in fix.md.
* - `$TODAY` across blocks in campaign.md (6 sites).
* - `$$` temp paths referenced outside their creating fence in fix.md.
* - plan.md asked the Read tool to expand
* `~/.claude/config-audit/sessions/*_/state.yaml`.
*
* The hardened pattern already present in drift.md is the target shape: a
* fixed literal temp path, repeated literally in every block that needs it.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
/** Shell variables supplied by the environment, not by a prior block. */
const AMBIENT = new Set([
'CLAUDE_PLUGIN_ROOT', 'ARGUMENTS', 'HOME', 'PATH', 'PWD', 'USER', 'TMPDIR',
]);
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/**
* Parse fenced blocks. Returns { lines, blockIndexOf(lineIdx) } where
* blockIndexOf returns -1 for prose outside any fence.
*/
function parseFences(content) {
const lines = content.split('\n');
const blocks = [];
let open = null;
lines.forEach((line, i) => {
const m = line.match(/^\s*```(\w*)/);
if (!m) return;
if (open === null) open = { lang: m[1], start: i };
else {
blocks.push({ lang: open.lang, start: open.start, end: i });
open = null;
}
});
const blockIndexOf = (i) => blocks.findIndex((b) => i > b.start && i < b.end);
return { lines, blocks, blockIndexOf };
}
/** Strip `#` comments from a bash line so the test never matches its own prose. */
function stripComment(line) {
const h = line.indexOf('#');
return h === -1 ? line : line.slice(0, h);
}
test('Shell state: no variable is referenced outside the block that assigned it', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
const { lines, blocks, blockIndexOf } = parseFences(content);
const assignedIn = new Map();
lines.forEach((raw, i) => {
const m = stripComment(raw).match(/^\s*([A-Z_][A-Z0-9_]*)=/);
if (!m) return;
const bi = blockIndexOf(i);
if (bi < 0) return;
if (!assignedIn.has(m[1])) assignedIn.set(m[1], new Set());
assignedIn.get(m[1]).add(bi);
});
lines.forEach((raw, i) => {
const line = stripComment(raw);
const bi = blockIndexOf(i);
const re = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g;
let m;
while ((m = re.exec(line)) !== null) {
const v = m[1];
if (AMBIENT.has(v)) continue;
// Skip the assignment site itself (`FOO=$FOO...` right-hand side is fine).
const eq = line.indexOf('=');
if (/^\s*[A-Z_][A-Z0-9_]*=/.test(line) && line.indexOf(m[0]) < eq) continue;
const where = assignedIn.get(v);
if (!where) {
violations.push(`${name}:${i + 1} $${v} is never assigned in any block`);
} else if (bi < 0) {
violations.push(
`${name}:${i + 1} $${v} referenced in prose/agent-prompt — no shell expands it there`,
);
} else if (!where.has(bi)) {
const lang = blocks[bi].lang || 'none';
violations.push(
`${name}:${i + 1} $${v} referenced in block ${bi} (lang=${lang}) but assigned only in block(s) ${[...where].join(', ')} — separate Bash calls, separate processes`,
);
}
}
});
}
assert.deepEqual(violations, [], `Cross-block shell-variable references:\n${violations.join('\n')}`);
});
test('Shell state: no $$ temp path is referenced outside the block that created it', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
const { lines, blockIndexOf } = parseFences(content);
const firstSeen = new Map();
const scan = (raw, i, cb) => {
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
let m;
while ((m = re.exec(stripComment(raw))) !== null) cb(m[1], i);
};
lines.forEach((raw, i) => scan(raw, i, (p) => {
if (!firstSeen.has(p)) firstSeen.set(p, { blk: blockIndexOf(i), line: i + 1 });
}));
lines.forEach((raw, i) => scan(raw, i, (p) => {
const origin = firstSeen.get(p);
if (origin.line === i + 1) return;
const bi = blockIndexOf(i);
if (bi < 0) {
violations.push(`${name}:${i + 1} ${p} referenced in prose — the Read tool cannot expand $$`);
} else if (bi !== origin.blk) {
violations.push(
`${name}:${i + 1} ${p} referenced in block ${bi} but created in block ${origin.blk}$$ is a different PID there`,
);
}
}));
}
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
});
test('Read tool: never asked to expand a glob', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
content.split('\n').forEach((raw, i) => {
// Only flag when the glob is the *object* of a Read instruction, i.e.
// "Read tool on `<glob>`" / "Read `<glob>`". Prose that merely explains
// that Read cannot expand a glob is the fix, not the defect — matching
// any co-occurrence on the line would flag this repo's own warning text.
const m = raw.match(/\bRead(?:\s+the)?(?:\s+tool)?\s+(?:tool\s+)?on\s+`([~/][^`]*)`|\bRead\s+`([~/][^`]*)`/);
if (!m) return;
const path = m[1] ?? m[2];
if (!path.includes('*')) return;
violations.push(`${name}:${i + 1} Read tool pointed at a glob \`${path}\` — use Glob`);
});
}
assert.deepEqual(violations, [], `Read-tool glob misuse:\n${violations.join('\n')}`);
});
test('state.yaml: phase commands name all four fields the rule requires', async () => {
// .claude/rules/state-management.md mandates current_phase, completed_phases,
// next_phase, updated_at after EVERY phase. A command that writes the file
// while naming only two fields silently drops the other two.
const REQUIRED = ['current_phase', 'completed_phases', 'next_phase', 'updated_at'];
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
if (!/Update\s+`?state\.yaml`?|Update state/i.test(content)) continue;
const missing = REQUIRED.filter((f) => !content.includes(f));
if (missing.length) {
violations.push(`${name} updates state.yaml but never names: ${missing.join(', ')}`);
}
}
assert.deepEqual(violations, [], `Incomplete state.yaml contracts:\n${violations.join('\n')}`);
});

View file

@ -68,13 +68,33 @@ async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8'); return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
} }
test('Group B: every file contains a Bash invocation block', async () => { // Agent-driven commands invoke no scanner, so they have no bash block to
// assert. analyze.md's only bash block used to be a `RAW_FLAG=` assignment
// referenced from the agent prompt below it — a prompt is not a shell, so the
// agent received the literal string `$RAW_FLAG` (session #49). Removing that
// block is the fix; requiring one here would re-assert the defect.
const AGENT_DRIVEN = new Set(['analyze.md']);
test('Group B: every scanner-invoking file contains a Bash invocation block', async () => {
for (const name of GROUP_B_FILES) { for (const name of GROUP_B_FILES) {
if (AGENT_DRIVEN.has(name)) continue;
const content = await readCommand(name); const content = await readCommand(name);
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`); assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
} }
}); });
test('Group B: agent-driven files spawn an Agent instead of a scanner', async () => {
for (const name of AGENT_DRIVEN) {
const content = await readCommand(name);
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
assert.doesNotMatch(
content,
/RAW_FLAG=/,
`${name} must not assign a shell variable it then references from an agent prompt`,
);
}
});
test('Group B: every file references the Read tool', async () => { test('Group B: every file references the Read tool', async () => {
for (const name of GROUP_B_FILES) { for (const name of GROUP_B_FILES) {
const content = await readCommand(name); const content = await readCommand(name);