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:
parent
de8a7b5d51
commit
09f817977c
17 changed files with 413 additions and 71 deletions
|
|
@ -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
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
|
||||||
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 $?
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
188
tests/commands/command-shell-state-shape.test.mjs
Normal file
188
tests/commands/command-shell-state-shape.test.mjs
Normal 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')}`);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue