From ad5916e3877c422e907421f5be23489716b214a4 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:12:15 +0100 Subject: [PATCH 1/9] docs(stall-handling-1-0-1): design Co-Authored-By: Claude --- docs/plans/stall-handling-1-0-1.md | 697 +++++++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 docs/plans/stall-handling-1-0-1.md diff --git a/docs/plans/stall-handling-1-0-1.md b/docs/plans/stall-handling-1-0-1.md new file mode 100644 index 0000000..7084cc0 --- /dev/null +++ b/docs/plans/stall-handling-1-0-1.md @@ -0,0 +1,697 @@ +# stall-handling-1-0-1 + +cw plugin release 1.0.1: sub-agent stall prevention and recovery, the lean orchestration +loop (plan C), and a session-labelled desktop notifier. Design date 2026-09-22, Claude Code +2.1.280, base `main` at 2e56128 (cw 1.0.0). + +## Context + +Three measured problems since 1.0.0 shipped, all recorded in the operator's project notes: + +1. **Stalls.** Deep-review `cw:reviewer` agents (`maxTurns: 20`) ended without a verdict + in 4 of 4 runs on 2026-09-21 and 2 of 4 on 2026-09-22; a resumed-turn nudge ("make no + further tool calls, return the report") recovered every one in a single turn. Two + `cw:implementer` runs on hook work exhausted 50 turns after the code was done, once + with the commit made and once with the tree uncommitted; the orchestrator recovered + both from `git status` plus the verification command. The skills and the orchestration + rule still prescribe "retry once fresh", which discards 30+ tool uses of context. + `memory: project` on the three reviewer agents spends turns writing memory files at the + end of a run (documented behaviour, Research B9b); one stall was traced to exactly that. +2. **Loop cost.** One session that ran a build, a ship, a merge panel and a design measured + about 1.6M sub-agent tokens plus a full orchestrator window: every reviewer returned a + 5–10k-token report inline, the orchestrator wrote a 730-line plan and edited it ~45 + times, research was folded verbatim, and the design loop allows 5 iterations plus a + direction pass. The operator's feedback on 2026-09-22 asked for a redesign, not patches. +3. **Notifications.** The operator's user-level `Notification` hook has an empty matcher and + a fixed text: 110 banners in one day, 62% of them the built-in `idle_prompt` 60–90 s after + a turn end while background agents still ran, 6% coinciding with a sub-agent result, no + session label across five concurrent sessions, and a Focus mode once swallowed a whole + day (99 hook runs, 0 banners). Design input: the public brief in the operator's + notification-hook folder (aggregate numbers only; nothing from its private notes is + copied here). + +Step 0 decisions (2026-09-22): premise validated, no cheap-invalidation detour; full plan C +folded in; notification presence policy ships behind a config flag, off by default; carry +the commit-pathspec and manifest nits, leave the protect-secrets self-exclusion for +`/cw:compound` (its exemption needs its own anchoring design). + +Intended outcome: a stalled agent is recovered in one resumed turn and stalls become rare; +a design costs two review rounds and a plan under 400 lines; each phase starts fresh; the +desktop shows one ping, titled ` · `, only when a human is needed. + +## Approach + +**A. Stall prevention (agents).** Drop `memory: project` from `reviewer`, `design-reviewer` +and `direction-reviewer` (fresh context is their point; memory writes cost turns). Raise +`maxTurns` to 40 on `reviewer` and `design-reviewer` (deep briefs need 30–40 tool uses, +measured); `direction-reviewer` keeps 15 (three read tools, never stalled); `implementer` +keeps 50 but gains a commit-first rule. Both reviewer schemas gain `"unchecked": [...]` so an +honest partial report is structured, not a stall. Every agent's Output section becomes a +contract: full report to the file the prompt names, final message ≤ ~1 500 tokens (the JSON +block plus the path), no side work. The Agent tool has no per-call `maxTurns`, so sizing +lives in the agent file plus brief sizing (Research B9a, B10). + +**B. Stall ladder (one home).** `rules/orchestration.md` replaces its NO_VERDICT bullet with +the ladder; the build and design skills point to it in one line each ("apply the stall +ladder in the orchestration rules"). Steps: (1) detect — the final message has no parseable +fenced JSON, or the result is marked partial after `maxTurns`; (2) ground truth first — +implementer: `git log` for the commit subject and the task's verification command; +reviewer: read its report file when it could write one (a design reviewer has no write tool +and returns JSON only); (3) nudge 1 by SendMessage to the same agent: +"Make no further tool calls. Your next message is the final report in the required JSON; +list what you did not reach under unchecked."; (4) nudge 2, harder: "No tool calls, no +memory, no prose before the block."; (5) respawn once, fresh, briefed with the partial report +and the unchecked list; (6) escalate. Nudges never consume a review iteration; the cap is two +nudges plus one respawn (Research BP7, BP20). Built-in `Explore`/`Plan` agents are one-shot +and skip to (5). Implementer variant: committed and verification passes → review, noting +the missing report; uncommitted and passes → one commit nudge ("stage and commit by +pathspec, nothing else"), then the orchestrator commits by pathspec with the task's +`commit:` line and records the fold at the checkpoint; verification fails → a failed task +for Phase 4, never a nudge. + +**C. Lean loop.** Bounded returns (every brief names a report file under the session +scratchpad; the agent writes there when it has a write tool or Bash; the return is ≤ ~1 500 +tokens; reviewers return findings JSON only). Design review capped at two rounds; a third +only when round two produced a verified critical finding; then escalate. `/cw:design + lite` (the word `lite` as the last token of the arguments, stripped before the +slug is derived): Explore only (one researcher only for a named unknown), one review round +(valve two), no direction pass; Step 0 recommends `lite` for fix-ups (existing research, ≤ 3 +files, no new subsystem). Direction pass only when Step 1 records the design as +architectural (a new subsystem, a public interface or schema, or a change to how the loop +itself works), whatever the path: "architectural" and "lite" are different axes. Research section is a +synthesis (≤ ~25 lines per angle: the findings that shaped a decision, grade, URL). Plan ≤ 400 +lines; a feature that needs more is two features (this two-track release, at about 600 lines, +is the operator's stated exception). Every checkpoint (design Step 7, build Phase 5, ship end) +tells the operator to start the next phase in a fresh session. Fast-path reviews run on +`sonnet` whatever the implementer's model; deep reviews stay on a model other than the +implementer's. That is an explicit exception to the reviewer-model rule (a fast path checks +conventions and the commit only, where a second model buys little — unsourced, a judgment +call backed only by the 2026-09-22 build, where four sonnet fast-path reviews of sonnet work +passed and the gate found nothing they missed), so T2 rewords the rule in +`rules/orchestration.md` and T4 the two spots in the build skill. A deep-review brief +carries at most six numbered checks; beyond that the orchestrator splits the review by file +ownership into two reviewers. +Plan authorship stays with the orchestrator while `/cw:design` runs in plan mode: whether a +sub-agent may write the plan file there is unverifiable (Research B12b) and the built-in +`Plan` agent has no Write tool; the size cap and the synthesis rule are the levers instead. +Note: this choice is unsourced — a judgment call. + +**D. Notifier.** New `hooks/notify.sh` bound in `hooks/hooks.json` to `Notification` (matcher +`permission_prompt|worker_permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|push_notification|idle_prompt`), +`Stop` and `StopFailure`. Immediate pings: the two permission types (a worker's prompt, from +agent teams, reads "Permission needed (worker)"), the two elicitation types, +`agent_needs_input` and `push_notification`. The "waiting for you" ping is the built-in +`idle_prompt`, which already waits 60 s for a human to answer, gated on state that `Stop` +records: every `Stop` writes ` ` (the length of `background_tasks`) to +`/.bg`, `StopFailure` writes `0` (a turn that ended on an API error is +idle too; Research A3), and `idle_prompt` pings only when the recorded count is 0. No record, +or one older than `stale_seconds` (1800), counts as 0: the hook fails open. This removes the +measured 62% at its cause (idle pings while background agents still run), keeps one ping per +genuinely waiting session across five terminals, and needs no frontmost-app probe (the +direction pass's reframe, adopted). Silent: `agent_completed`, `auth_success`, +`computer_use_enter`, `computer_use_exit` and the three quota types. Inside a sub-agent +(`agent_id` present) only the two permission types pass, the message naming `agent_type`. +Title = ` · `, the derived `-` form collapsed to +` · `, folder alone when the registry file is absent or unparsable. Presence +routing is a config flag, default off, and covers routing only: away (HIDIdleTime ≥ 300 s or +a locked screen, Darwin probes) with a Remote Control bridge id in the registry skips the +banner (the phone channel delivers), away without one banners; any probe failure counts as +"at the machine", so a probe can never silence a needed ping. Delivery passes text as +`osascript` argv (`on run argv`), never interpolated into script source; `notify-send` on +Linux when present; otherwise no-op. Always exit 0 (exit 2 on `Stop` would block the turn). +Headless guard: `CLAUDE_CODE_ENTRYPOINT` must be `cli` or unset (configurable). The config +file `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cw-notify.json` is the opt-in: the hook is silent +until it exists (an empty `{}` turns it on with defaults), because a published plugin must +not add banners to installs that did not ask for them; `CW_NOTIFY=0` is the kill switch; +`CW_NOTIFY_DRY_RUN=1` prints the would-be banner (the harness and CI path). Brief open +decisions resolved: JSON config file plus one env kill switch; `agent_needs_input` kept +(rare, cheap); `CLAUDE_CLIENT_PRESENCE_FILE` not touched this round; 300 s / 1800 s +configurable (unsourced defaults); the frontmost rule dropped. + +**E. Carry-ins.** Build briefs say `git add -- && git commit -- ` +(the 2026-09-22 index race); `marketplace.json` gains the top-level `description` that +`claude plugin validate` warns about; `plugin.json` version 1.0.1 (semver would call a new +hook 1.1.0; the slug fixes 1.0.1 — flagged at the checkpoint). + +Alternatives set aside: respawn-only recovery (loses context; the measured recovery is the +nudge); a per-call turn budget (not in the Agent tool); `Stop` as the "waiting" signal (pings +every turn end, attended ones included, and its frontmost-app patch silences every session +at once — rejected by the direction pass); `idle_prompt` ungated (its payload lacks +`background_tasks`; solved by recording the count at `Stop`); the frontmost app via System +Events (Automation prompt can hang a background process, error -1743 after a rejection) or +`lsappinfo` (cannot tell five sessions apart); touching `CLAUDE_CLIENT_PRESENCE_FILE` from the hook (side effects on the phone +channel, deferred); working around a macOS Focus mode that drops banners (an OS setting the +hook cannot see; out of scope — the phone channel over Remote Control is the operator's +mitigation for it). + +## Files to create/modify + +| Path | Change | +|---|---| +| `agents/reviewer.md` | `maxTurns: 40`, no `memory:`, budget/report-first rules, schema with `unchecked`, bounded final message | +| `agents/design-reviewer.md` | `maxTurns: 40`, no `memory:`, budget rule, schema with `unchecked`; JSON only (no write tool) | +| `agents/direction-reviewer.md` | no `memory:` (keeps 15), bounded final message | +| `agents/implementer.md` | commit-first rule, exit report last, ≤ ~10 lines before the JSON block | +| `agents/researcher.md` | Output: ≤ ~1 500 tokens, numbered findings with grade and URL, unsourced list | +| `rules/orchestration.md` | stall ladder (the one home; keeps the single `NO_VERDICT` token), two-round cap wording, bounded-return rule, checkpoint-says-fresh-session | +| `rules/workflow.md` | `/cw:design [lite]`, `/clear` between phases | +| `skills/design/SKILL.md` | lite flag, two-round cap, synthesis research, plan ≤ 400 lines, report path + budget in briefs, ladder pointer, fresh-session line; ≤ 200 lines | +| `skills/build/SKILL.md` | report path + budget + commit pathspec in briefs, missing-report gate branches, brief sizing, fast-path on sonnet, ladder pointer, fresh-session line; ≤ 200 lines | +| `skills/ship/SKILL.md` | fresh-session line at the end | +| `hooks/notify.sh` (new) | the notifier, spec in Task T5 | +| `hooks/hooks.json` | `Notification` (matcher incl. `idle_prompt`), `Stop`, `StopFailure` entries, timeout 5; existing three entries untouched | +| `tests/run.sh` | `hooks_notify_cases` + `notify_case` helper, wired into `cmd_hooks` | +| `README.md` | agents table gains a turn-cap column, "Notifications" section, loop description (rounds, lite, ladder pointer) | +| `WORKFLOW.md` | loop semantics paragraph updated; ≤ 200 lines | +| `docs/plans/cw-plugin/cw-plugin-design-draft.md` | "Release 1.0.1" section, ≤ 40 lines | +| `.claude-plugin/plugin.json` | `"version": "1.0.1"` (T7, with the release gate) | +| `.claude-plugin/marketplace.json` | top-level `description` (T7) | + +Patterns to follow: stdin/jq/exit conventions in `hooks/profile-check.sh:14-19` (`trap finish +EXIT`, `# shellcheck disable=SC2317,SC2329`, always exit 0) and `hooks/protect-secrets.sh:14` +(field fallback with `//`); harness helpers `report`, `check_code`, `push_case` in `tests/run.sh:31-66` +and the jq-absent PATH shim in `hooks_jq_absent_case` (`tests/run.sh:203`); `${CLAUDE_PLUGIN_ROOT}` +command form in `hooks/hooks.json:9`. + +## Dependencies + +None new. `jq` (already required by the other hooks; absence tolerated). macOS system tools +`osascript`, `ioreg`, `plutil`; Linux `notify-send` optional. Claude Code ≥ 2.1.145 +for `background_tasks`; verified against 2.1.280. + +## Risks & mitigations + +1. **Size gate.** Baseline 73 738 bytes, tree at 59 773 (81.1%); caps 200 lines for the design + and build skills, 150 for every other scoped file, 200 for WORKFLOW.md. Every task carries + a line cap in its verification; growth budget: T1 ≤ +1 800 bytes, T2 ≤ +2 500, T3 ≤ +800, + T4 ≤ +1 200 (net of the compaction each skill needs to stay under 200 lines). Detected by + `bash tests/run.sh size` at T7; rollback is a minimisation pass on the same file by the + same task (one home per rule: skills reference the rules, they do not restate them). +2. **Dedupe gate.** `tests/dedupe-phrases.txt` phrases (`NO_VERDICT`, `counter-model`, `ground + truth over`, `one atomic commit per task`, `in the same turn`, `explicit model`, `never + commits`, `two human checkpoints`, `context7 before`, …) must each stay in exactly one + scoped file. New skill and agent text says "missing verdict" and "stall ladder", never the + token `NO_VERDICT`; T1–T4 verifications grep for the token. +3. **Registry is undocumented and unstable** (not updated on `/clear`, deleted at process exit; + Research A7). The label falls back to the folder; the harness uses fixture files; a parse + error never blocks. If the schema changes, the banner loses its session part and nothing + else. +4. **Exit code on `Stop`.** Exit 2 would block the turn from ending. The EXIT trap forces 0; + every harness case asserts exit 0, including malformed stdin and jq absent. +5. **Text injection.** Payload `message` reaches `osascript` only as argv; the harness case with + quotes, backslashes and newlines proves one sanitised line. +6. **Presence probes.** HIDIdleTime is contested under Screen Sharing and on some Apple Silicon + reports; the screen-lock key is absent while unlocked; System Events prompts (Research + BP15–17). Presence is off by default; probes use `ioreg` and `plutil` only, no Apple + Events; any failure reads as "at the machine". +7. **`Stop` reliability.** Claude Code issues #29881/#87972 report `Stop` not firing on + stall-terminated turns, with `StopFailure` firing instead. A missed `Stop` leaves a stale + record; `StopFailure` writes 0 and a record older than `stale_seconds` counts as 0, so the + worst case is one late ping, never a silenced session. The brief's issue #15250 could not + be located (unverified). +8. **`maxTurns` enforcement.** One 2026 issue (#41143) reports it unenforced; the operator's + runs on 2.1.27x–2.1.280 hit the cap at exactly 20 and 50. The ladder works either way. +9. **Nudge loops.** Capped at two nudges plus one respawn (deterministic backstop; Research + BP7, BP20). +10. **Live hooks.** The plugin is live in the operator's profile; a malformed `hooks.json` + silently disables the guard hooks for new sessions. T5 writes the new file to a temp path, + validates with `jq .`, diffs the three existing entries for byte identity, then moves it in. +11. **The private step.** The notifier is opt-in, so nothing changes for the operator until + they create `cw-notify.json` and, in the same by-hand step, remove the static user-level + Notification hook from both profiles. Out of PR by rule; noted at the checkpoint. Other + installers see no banner unless they create the file. +12. **Plan mode and sub-agent writes.** If a later Claude Code version lets a sub-agent write the + plan file in plan mode, delegated authorship becomes a one-line change in Step 3; the + design records the constraint rather than designing around it. +13. **Rules edited inside a feature PR.** `rules/workflow.md` keeps process-rule edits out of + feature PRs; in this repository the rules files are the product, and T2 edits them under + the same accepted-risk exemption fixups-1-0-0 logged. Recorded, not litigated. +14. **CI hygiene greps.** `.github/workflows/ci.yml` rejects emphatic capitals (`NEVER|ALWAYS| + MUST|CRITICAL` as words), banned coordination phrases, `under 50 lines`, internal paths + (`~/.claude/plans`, `gitattributes`, `linguist`, `git clean`) in skills, an agent file + missing `model`/`tools`/`effort`/`maxTurns`, a design skill without the word `haiku`, and + the LICENSE name anywhere in prompts or docs. T1–T4 verifications carry the greps that + apply to their files and T7 runs the whole set, so a red CI after the push cannot come + from this series. +15. **Undocumented environment.** `CLAUDE_PID` and `CLAUDE_CODE_ENTRYPOINT` are in the binary and + in live sessions but not in the hooks reference. Both are used fail-open: no `CLAUDE_PID` + → folder-only title; no `CLAUDE_CODE_ENTRYPOINT` → proceed. + +## Success criteria + +1. `bash tests/run.sh all` prints `all: hooks: PASS`, `all: size: PASS`, `all: dedupe: PASS`, + `all: settings-keys: PASS`, `all: trailers: PASS` and exits 0; the hooks line reports at + least 140 passed (108 existing + 32 notifier cases), 0 failed. +2. `shellcheck -x hooks/*.sh tests/run.sh` exits 0 on 0.11 (local) and 0.9.0 (CI's). +3. `out=$(claude plugin validate . 2>&1) && ! printf '%s' "$out" | grep -qi warning` exits 0, + and `claude plugin validate .claude-plugin/plugin.json` exits 0 (its root-CLAUDE.md + warning is pre-existing and accepted). +4. `grep -L '^memory:' agents/reviewer.md agents/design-reviewer.md agents/direction-reviewer.md` + lists all three; `grep -c '^maxTurns: 40' agents/reviewer.md agents/design-reviewer.md` = 1 + each; `grep -c '"unchecked"' agents/reviewer.md agents/design-reviewer.md` ≥ 1 each. +5. `grep -rl 'NO_VERDICT' skills agents rules` lists exactly `rules/orchestration.md`; + `grep -c 'stall ladder' rules/orchestration.md` ≥ 1, `skills/build/SKILL.md` ≥ 1, + `skills/design/SKILL.md` ≥ 1; `grep -c 'lite' skills/design/SKILL.md` ≥ 3. +6. `wc -l` ≤ 200 for `skills/design/SKILL.md`, `skills/build/SKILL.md`, `WORKFLOW.md`; ≤ 150 for + every other file under `skills/`, `agents/`, `rules/`. +7. The 32 notifier cases in T5 pass under `bash tests/run.sh hooks` on macOS bash 3.2, bash 5 + and ubuntu (dry run, no `osascript`). +11. The CI hygiene greps of Risk 14 all pass on the final tree (T7 runs them verbatim). +8. `git log --format=%s main..HEAD` holds the seven task subjects once each plus + `docs(stall-handling-1-0-1): design` (eight commits), one exact trailer per commit. +9. Field acceptance (manual, after `claude plugin update cw` and the config file): a + permission prompt shows one banner titled ` · `; a run with three + parallel sub-agents shows no banner while they run and one "Waiting for your input" + banner about 60 s after the final turn ends; a turn the operator answers within 60 s + shows nothing; banners per day fall from ~110 to the day's genuine waits. +10. `grep -E -c '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|@[a-z0-9.-]+\.[a-z]{2,}' docs/plans/stall-handling-1-0-1.md` = 0 + and the LICENSE-name gate passes on the plan. + +## Research + +### Codebase (Explore, haiku) + +- Retry rule to replace: `rules/orchestration.md:37-38` ("retry once fresh, then escalate"); + `skills/build/SKILL.md:149-152` (missing verdict → one fresh reviewer); `skills/design/SKILL.md:146-147`. + Caps: `skills/design/SKILL.md:3,148-150` (5 iterations); `skills/build/SKILL.md:154-165` + (three iterations, kept); `rules/orchestration.md:45-46` ("one or two rounds"). +- Verbatim research fold: `skills/design/SKILL.md:74-77`. Direction pass: `:152-156`. Plan + authorship: `:83-98` (orchestrator); build commits the design at `skills/build/SKILL.md:48-51`. +- Frontmatter: `agents/reviewer.md:6,12` (20, memory), `agents/design-reviewer.md:6,18` (25, + memory), `agents/direction-reviewer.md:6,11` (15, memory), `agents/implementer.md:6` (50), + `agents/researcher.md:6` (30). +- Harness: `tests/run.sh:16` `HOOKS_DIR="${CW_HOOKS_DIR:-$ROOT/hooks}"`; helpers `report`, + `check_code`, `push_case` at `:31-66` (payload via `jq -nc`, `HOME="$SCRATCH/base"`, + `"$BASH_BIN"`); `cmd_hooks` at `:546-575` lists the case groups; jq-absent shim at `:203`; + size at `:14` (`BASELINE_BYTES=73738`), line caps `:681-700`, WORKFLOW cap `:703-711`; + `check_settings_keys` `:1087-1107`; `cmd_all` `:1110`. +- Hook conventions: `hooks/profile-check.sh:14-19` (`trap finish EXIT`, always exit 0, JSON on + stdout); `hooks/protect-branches.sh:56-62` (jq absent → exit 0; `INPUT=$(cat)`); + `hooks/hooks.json:1-38` (`${CLAUDE_PLUGIN_ROOT}`, `timeout: 10`). CI: `shellcheck -x hooks/*.sh tests/run.sh`. +- Docs: `README.md:82-91` agents table (no turn-cap column yet); `:164-181` settings example notes; + `WORKFLOW.md:102` names the verdict semantics. Task format exemplar: + `docs/plans/hardening-1-0-0.md:426-482`. +- Current sizes (`wc -l`): design 192 lines / 9 803 B, build 183 / 9 782, ship 124, orchestration 60, + workflow 38, reviewer 55, design-reviewer 85, direction-reviewer 79, implementer 44, + researcher 59, WORKFLOW.md 130, README 289. + +### Docs and API (researcher, sonnet; orchestrator checks on the 2.1.280 binary) + +- **A1 Notification types**: the 2.1.280 binary's enum is exactly `permission_prompt, idle_prompt, + auth_success, elicitation_dialog, agent_needs_input, agent_completed, elicitation_url_dialog, + worker_permission_prompt, push_notification, computer_use_enter, computer_use_exit, + quota_auto_resume_fired, quota_auto_resume_stale, quota_auto_resume_disabled`; + `push_notification` is emitted when the push tool runs. The docs page lists a subset and + adds `elicitation_complete`/`elicitation_response` (present as strings, not in the enum) — + https://code.claude.com/docs/en/hooks. Matcher = regex over `notification_type`; text in `message`. +- **A2 `agent_id`/`agent_type`**: "Present only when the hook fires inside a subagent call" (hooks + reference, general hook input; no per-event exclusion stated). The design labels when present + and never depends on it. +- **A3 `Stop` and `StopFailure`**: payload `background_tasks` (2.1.145+), `stop_hook_active`, `last_assistant_message`; + matchers ignored; exit 2 blocks stopping; stdout not shown — https://code.claude.com/docs/en/hooks. + **`StopFailure`**: in the binary's event list; docs: fires "when the turn ends due to an API + error", output discarded. Plugins may bind `Notification` and `Stop` + (https://code.claude.com/docs/en/hooks-guide). Default command-hook timeout 600 s (the entry + sets 5 s). +- **A4 Env**: `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_PID`, + `CLAUDE_CODE_SESSION_ATTENDED` present in the binary and in this session's environment but + absent from the hooks reference (undocumented; used fail-open, Risk 15). + `CLAUDE_CODE_SESSION_ATTENDED` is static at spawn, so it is set aside as a presence signal. + `CLAUDE_CLIENT_PRESENCE_FILE` documented (2.1.181+, https://code.claude.com/docs/en/env-vars). + `messageIdleNotifThresholdMs` exists in source, not in the settings schema — not relied on. + Documented plugin mechanisms set aside with reasons: `CLAUDE_PLUGIN_DATA` (persistent data + dir; a per-session state file is ephemeral, so `TMPDIR` fits better) and plugin options + (`${user_config.*}` / `CLAUDE_PLUGIN_OPTION_`; install-time prompting and untested on + a symlinked local plugin — a JSON file the harness can fixture is the safer v1; unsourced, + judgment call) — https://code.claude.com/docs/en/hooks. +- **A7 Registry** `~/.claude/sessions/.json`: undocumented internal (issues #95439, #36213, + #85281); on this machine (2.1.280) it holds `sessionId, cwd, name, nameSource, status, + bridgeSessionId, entrypoint, kind, …`; interactive terminal sessions report `entrypoint: cli`. +- **B9–B11 Sub-agents** (https://code.claude.com/docs/en/sub-agents): B9a on `maxTurns` + exhaustion the output is marked partial and an agent id is returned; B10 resume by id or + name keeps the full history, built-in Explore/Plan are one-shot; B9b `memory` scopes give + the agent Read/Write/Edit on its memory dir and it spends tool calls writing there; + `background: true` strips Agent, AskUserQuestion, ExitPlanMode; B11 docs recommend "1–3 + paragraphs" summaries and verbose output to files. **B12** `plansDirectory` documented; a + sub-agent writing the plan file in plan mode (B12b): unverifiable. **A8** Changelog + 2.1.279–280: `SubagentStop` matcher fix, Stop prompt-hook label; nothing on Notification. + +### Best practices (researcher, sonnet) + +- BP1–5 (H/M): no framework forces a final answer on budget exhaustion — Claude Agent SDK + `error_max_turns`, OpenAI `MaxTurnsExceeded`, LangGraph recursion error, AutoGen + `MAX_TURNS` — so the orchestrator owns recovery. https://code.claude.com/docs/en/agent-sdk/agent-loop +- BP6 (M): resume the same named agent, do not respawn; respawn loses context. + https://github.com/laurigates/claude-plugins/blob/main/agent-patterns-plugin/skills/parallel-agent-dispatch/references/failure-recovery.md +- BP7/BP20 (M): 94/98 nudge-recoverable failures were "computed but not presented as final"; + cap nudges (≈3) with a deterministic backstop or the nudge loop becomes the loop. + https://arxiv.org/pdf/2606.24839 +- BP8–10 (M): git log as ground truth for stranded implementers is the converged pattern; no + measured better alternative. https://github.com/trinity-ai-labs/orchestration-skills/issues/440 +- BP11–13 (H/M): review depth tiered by size/risk — Anthropic Code Review (84% of >1 000-line + PRs get findings vs 31% under 50 lines, <1% wrong) https://claude.com/blog/code-review; Meta + RADAR auto-lands low-risk diffs at one third the revert rate https://arxiv.org/html/2605.30208v1. +- BP14: no round-over-round false-positive curve beyond the prior 75% figure (gap). +- BP15–17 (M/H): HIDIdleTime contested (Screen Sharing, some Apple Silicon reports) + https://developer.apple.com/forums/thread/721530; System Events `tell` blocks trigger an + Automation prompt that can hang a non-interactive process, -1743 after rejection + https://scriptingosx.com/2020/09/avoiding-applescript-security-and-privacy-requests/; + screen-lock key absent while unlocked, `None` over SSH. +- BP18–19 (L): #15250 not found; #29881/#87972 describe `Stop` missing stall-terminated turns + with `StopFailure` firing instead. https://github.com/anthropics/claude-code/issues/29881 +- BP21–22 (L): a live turn budget cannot be resized (argument for resume over respawn); no + source argues the ladder is over-engineering (gap). +- Prior research 2026-09-22 (operator's notes): ≤ 1–2k-token returns with file reports (H), + delegated authorship (M), clear context per phase (H), two-round cap (M), synthesis (L). + +# Tasks + +Seven commits. T1–T5 own disjoint files and run together; T6 (docs) after T1–T5; T7 +(release manifests, whose verification is the release gate) last. Review depth follows the +build skill's own rule: T5 deep on `opus` (risk high: executable code on every `Stop`, +payload text reaching `osascript`), T1 deep on `opus` (five files, the behavioural core), +T3 and T4 deep on `sonnet` (risk high: compressed rewrites of the two skills that run the +loop; the reviewer's checklist is "every specification bullet landed, every pre-existing +safety rule and anti-pattern survived, the file is under its cap"), T2, T6 and T7 fast-path +on `sonnet`. Fix iterations in this build never use `git commit --amend`: T1–T5 commit in +parallel, so a fix goes in as `git commit --fixup= -- ` and the +orchestrator squashes it with `GIT_SEQUENCE_EDITOR=true git rebase --autosquash --no-autostash +~1` once every agent has returned, before T7's count runs. + +Note to the `/cw:build` orchestrator, Phase 0: fresh session, auto mode, plain +`/cw:build stall-handling-1-0-1` (branch `feat/stall-handling-1-0-1` from `main`; the +plugin files live on `main` since 2e56128). Before the design commit run +`N=$(sed -n 's/^Copyright (c) [0-9]* //p' LICENSE | cut -d' ' -f1); ! grep -qi "$N" docs/plans/stall-handling-1-0-1.md` +(CI's name gate covers `docs`). The design commit ends with exactly one trailer line per +`rules/workflow.md` and no session line; check it right after committing. + +Note on briefing, for every agent in this build: the orchestrator's context is the scarce +resource. Each implementer and each `cw:reviewer` (it has Bash) writes its full report to a +file under the session scratchpad (`reports/-.md`; pass the absolute path in the +brief) and returns at most about 1 500 tokens: the required JSON block plus a few lines +naming the file. Reviewers return findings only (severity, file:line, one reproducing +command each), never evidence walkthroughs. Every brief states the agent's turn budget (40 +for reviewers, 50 for implementers) and that the report comes before any optional check. +Implementers stage and commit by explicit pathspec — `git add -- && git commit +-- ` — because T1–T5 share one index; on an `index.lock` or ref-lock error they +wait a few seconds and retry the git command. Prompt text in skills, agents and rules must +pass the CI greps of Risk 14 (no `NEVER`/`ALWAYS`/`MUST`/`CRITICAL` as words, no internal +paths in skills, every agent keeps `model`, `tools`, `effort`, `maxTurns`). T1–T4 run +alongside T5 and call no `tests/run.sh` subcommand; T5's verification runs `bash tests/run.sh +hooks`, which reads only `hooks/` and `tests/`. A stalled agent (no JSON block, or a partial +result) gets the ladder from Approach B: ground truth, one report-only nudge by SendMessage, +a second, then one respawn — never a fresh respawn first. + +## Task T1 +- scope: Turn the five agent files into stall-resistant contracts: no memory on reviewers, sized turn caps, `unchecked` in both reviewer schemas, report-first and commit-first rules, bounded final messages. +- files_owned: [agents/reviewer.md, agents/design-reviewer.md, agents/direction-reviewer.md, agents/implementer.md, agents/researcher.md] +- files_forbidden: [rules/, skills/, hooks/, tests/, README.md, WORKFLOW.md, docs/, .claude-plugin/] +- depends_on: none +- agent: implementer +- model: sonnet +- risk: normal +- verification: `cd "$(git rev-parse --show-toplevel)" && ! grep -q '^memory:' agents/reviewer.md agents/design-reviewer.md agents/direction-reviewer.md && [ "$(grep -c '^maxTurns: 40$' agents/reviewer.md)" -eq 1 ] && [ "$(grep -c '^maxTurns: 40$' agents/design-reviewer.md)" -eq 1 ] && grep -q '^maxTurns: 15$' agents/direction-reviewer.md && grep -q '^maxTurns: 50$' agents/implementer.md && grep -q '"unchecked"' agents/reviewer.md && grep -q '"unchecked"' agents/design-reviewer.md && grep -q 'before any optional check' agents/implementer.md && grep -qi '1 500\|1,500\|1500' agents/researcher.md && [ "$(cat agents/*.md | wc -c | tr -d ' ')" -le 13806 ] && ! grep -rn 'NO_VERDICT\|' agents/ && ! git grep -nwE 'NEVER|ALWAYS|MUST|CRITICAL' -- agents && for f in agents/*.md; do [ "$(wc -l < "$f")" -le 150 ] || exit 1; for k in model tools effort maxTurns; do grep -q "^$k:" "$f" || exit 1; done; done`, expected exit 0 +- commit: fix(agents): no reviewer memory, sized turn caps, unchecked lists and bounded reports + +Specification: +- `reviewer.md`: frontmatter `maxTurns: 40`, delete the `memory: project` line. Reword the + description (line 3) and the read-only sentence (line 19) to "no Write or Edit tool; Bash + writes only the report file the prompt names, nothing else in the tree", so the report + rule below does not contradict them. Add a `## Budget` + section (≤ 8 lines): the turn budget is finite and stated in the prompt; open the report file + the prompt names on the first turn and append each finding as it is confirmed (Bash `>>`); + emit the final JSON when the checks are done or about two thirds of the budget is spent, + whichever comes first; unreached checks go under `unchecked`; no memory notes, no summaries + beyond the report. Output schema becomes + `{"verdict": "PASS|NEEDS_WORK", "findings": [...], "unchecked": ["..."]}`; the final message + is the JSON block plus one line naming the report file, nothing else. Severity wording and + the PASS/critical rule stay. +- `design-reviewer.md`: `maxTurns: 40`, delete `memory: project`; a shorter Budget section + (no write tool, so no report file: keep a running list of confirmed issues in your own + reasoning and emit the JSON at two thirds of the budget at the latest, unreached checks + under `unchecked`); schema gains `"unchecked": ["..."]` beside `issues` and + `suggested_fixes`. +- `direction-reviewer.md`: delete `memory: project`; add one line to Output: the JSON is the + whole final message. +- `implementer.md`: in Rules, replace the commit bullet with: as soon as `verification` passes, + stage and commit by explicit pathspec (`git add -- && git commit -- `) before + any optional check, tidy-up or re-read; the exit report is the last thing written; never + spend turns on notes or memory. Output: full notes to the report file the prompt names; + the final message is at most about ten lines plus the exit-report JSON block. +- `researcher.md`: Output section: at most about 1 500 tokens — numbered findings of 2–3 + lines each with a grade (H official docs / M practitioner with numbers / L inference) and + one URL, then a short "unsourced" list; no narrative, no restated questions. +- Keep every file ≤ 150 lines; do not touch other sections' wording. + +## Task T2 +- scope: Make `rules/orchestration.md` the single home of the stall ladder and the two-round cap, and add the lite flag and per-phase `/clear` to `rules/workflow.md`. +- files_owned: [rules/orchestration.md, rules/workflow.md] +- files_forbidden: [rules/browser.md, agents/, skills/, hooks/, tests/, README.md, WORKFLOW.md, docs/, .claude-plugin/] +- depends_on: none +- agent: implementer +- model: sonnet +- risk: normal +- verification: `cd "$(git rev-parse --show-toplevel)" && [ "$(grep -c 'NO_VERDICT' rules/orchestration.md)" -eq 1 ] && grep -q 'stall ladder' rules/orchestration.md && grep -q 'SendMessage' rules/orchestration.md && grep -qi 'two nudges' rules/orchestration.md && grep -q 'whatever the implementer' rules/orchestration.md && [ "$(grep -c 'counter-model' rules/orchestration.md)" -eq 1 ] && ! grep -q 'retry once fresh' rules/orchestration.md && ! grep -q 'one or two rounds' rules/orchestration.md && grep -q 'lite' rules/workflow.md && grep -q 'between phases' rules/workflow.md && [ "$(sed -n 's/^ \(Co-Authored-By: .*\)$/\1/p' rules/workflow.md | wc -l | tr -d ' ')" -eq 1 ] && ! grep -rn '' rules/ && ! git grep -nwE 'NEVER|ALWAYS|MUST|CRITICAL' -- rules && [ "$(wc -l < rules/orchestration.md)" -le 150 ] && [ "$(wc -l < rules/workflow.md)" -le 150 ] && [ "$(wc -c < rules/orchestration.md)" -le 6400 ]`, expected exit 0 +- commit: feat(rules): stall ladder as the one home, two-round review cap, lite design path + +Specification: +- `rules/orchestration.md` "Verification & completion": replace the NO_VERDICT bullet with a + `stall ladder` sub-list (≤ 14 lines) worded per Approach B: detection (no fenced JSON, or a + partial result after `maxTurns`) is NO_VERDICT, distinct from NEEDS_WORK; ground truth + first (git + verification for an implementer, the report file for a reviewer); nudge 1 and + nudge 2 by SendMessage to the same agent with the quoted texts; one fresh respawn briefed + with the partial report; escalate; two nudges plus one respawn is the cap; nudges consume no + iteration; built-in Explore/Plan agents cannot be resumed and go straight to the respawn; + the implementer variant (committed → review; uncommitted and green → commit nudge, then + the orchestrator commits by pathspec and records the fold; red → failed task). +- "Review-loop anti-ratchet": rewrite the cap bullet to "two rounds by default; a third only + when round two produced a verified critical finding; then escalate" (this resolves the + documented mismatch with the design skill). +- "Verification & completion", the reviewer bullet (line 33): keep the counter-model wording + for deep reviews (the phrase stays in this file only) and add that a fast-path review + (conventions and commit check) may run on `sonnet` whatever the implementer's model. +- "Sessions & context": add that every checkpoint tells the operator to start the next phase + in a fresh session, and that every brief names a report file and asks for a return of at + most about 1 500 tokens (findings JSON only for reviewers). +- `rules/workflow.md`: the `/cw:design` bullet gains `[lite]` (Explore only, one review round, + no direction pass; for fix-ups); Context hygiene: "`/clear` between phases, not only between + unrelated tasks". Keep the dedupe phrases where they are. + +## Task T3 +- scope: Rewrite `skills/design/SKILL.md` for the lean loop: lite flag, two-round cap, synthesis research, plan size cap, report paths and budgets in briefs, ladder pointer, fresh-session checkpoint, within 200 lines. +- files_owned: [skills/design/SKILL.md] +- files_forbidden: [skills/build/, skills/ship/, skills/compound/, skills/search/, skills/google-workspace/, agents/, rules/, hooks/, tests/, README.md, WORKFLOW.md, docs/, .claude-plugin/] +- depends_on: none +- agent: implementer +- model: opus +- risk: high +- verification: `cd "$(git rev-parse --show-toplevel)" && f=skills/design/SKILL.md && [ "$(wc -l < $f)" -le 200 ] && [ "$(grep -c 'lite' $f)" -ge 3 ] && grep -qi 'one round' $f && grep -qi 'architectural' $f && grep -q 'stall ladder' $f && grep -q '400 lines' $f && grep -qi 'fresh session' $f && grep -q 'haiku' $f && ! grep -q 'verbatim enough' $f && ! grep -q '5 iterations\|after 5' $f && grep -q 'ExitPlanMode' $f && grep -q '# Tasks' $f && ! grep -q 'NO_VERDICT\|' $f && ! git grep -nwE 'NEVER|ALWAYS|MUST|CRITICAL' -- $f && ! git grep -niE '~/.claude/plans|gitattributes|linguist|git clean' -- $f && [ "$(wc -c < $f)" -le 10600 ]`, expected exit 0 +- commit: feat(design): lite path, two-round cap, synthesis research and bounded briefs + +Specification (keep the seven-step shape, the `ExitPlanMode` transition, Step 6's move and +Step 7's checkpoint text; compact prose elsewhere to fit): +- Input: the feature description, optionally ending in the word `lite`; that last token is + stripped before the slug is derived. Step 0 adds: recommend `lite` when the brief is a + fix-up (research exists, ≤ 3 files, no new subsystem); the operator's answer decides. +- Step 2: full path = Explore (haiku) + the two researchers; lite = Explore only, one + researcher only for an unknown Step 1 named. Every brief names a report path under the + session scratchpad where the agent can write, states the turn budget, and asks for a + return of at most about 1 500 tokens; the ultracode alternative stays as one sentence. +- Step 3: Research is a synthesis — at most about 25 lines per angle: the findings that + shaped a decision, each with grade and URL; full returns are not pasted. The plan stays + under 400 lines; when Step 1 finds more than eight tasks, split the feature instead. +- Step 4: two rounds by default; a third only when round two produced a verified critical + finding; then escalate with the surviving findings. Lite: one round, a second only on a + verified critical finding. A missing verdict → the stall ladder in the orchestration rules + (one line, no restatement). Everything else in the loop (verify + critical findings, minor notes, fresh reviewer per pass) stays. +- Step 1 records `architectural: yes|no` (a new subsystem, a public interface or schema, or + a change to how the loop itself works). Step 4b runs only on `yes`, whatever the path. +- Steps 5 and 7: the summary states rounds used and the path; the checkpoint adds "start + `/cw:build` in a fresh session". The frontmatter description drops "escalating after 5 + iterations" for "two review rounds". + +## Task T4 +- scope: Rewrite the build skill's briefing, gate and review phases for bounded returns, commit pathspec, the missing-report branches, brief sizing and the ladder pointer; add the fresh-session line to ship. +- files_owned: [skills/build/SKILL.md, skills/ship/SKILL.md] +- files_forbidden: [skills/design/, skills/compound/, skills/search/, skills/google-workspace/, agents/, rules/, hooks/, tests/, README.md, WORKFLOW.md, docs/, .claude-plugin/] +- depends_on: none +- agent: implementer +- model: opus +- risk: high +- verification: `cd "$(git rev-parse --show-toplevel)" && b=skills/build/SKILL.md && s=skills/ship/SKILL.md && [ "$(wc -l < $b)" -le 200 ] && [ "$(wc -l < $s)" -le 150 ] && grep -q 'git commit -- ' $b && grep -q 'stall ladder' $b && grep -qi 'fresh session' $b && grep -qi 'fresh session' $s && grep -q 'six numbered checks\|six checks' $b && grep -q 'whatever the implementer' $b && grep -q 'fixup' $b && ! grep -q 'git commit --amend' $b && grep -q '"unchecked"' $b && ! grep -q 'spawn one fresh reviewer on the same' $b && ! grep -q "on the implementer's own model" $b && grep -q 'commit: none' $b && ! grep -q 'NO_VERDICT\|' $b $s && ! git grep -nwE 'NEVER|ALWAYS|MUST|CRITICAL' -- $b $s && ! git grep -niE '~/.claude/plans|gitattributes|linguist|git clean' -- $b $s && [ "$(wc -c < $b)" -le 11000 ]`, expected exit 0 +- commit: feat(build): bounded briefs, commit by pathspec, stranded-implementer gate and the ladder pointer + +Specification (`skills/build/SKILL.md`, keep the five-phase shape and every existing +safety rule): +- Phase 2 prompt list: add the report path, the turn budget (50), "return at most about + 1 500 tokens"; the commit instruction becomes explicit — `git add -- && git + commit -- `, never a bare `git commit` (shared index across parallel tasks). +- Phase 2→3 gate: a missing or partial exit report is not a failure by itself. Ground truth + decides: commit present and verification green → review, noting the missing report; + tree green but uncommitted → one commit nudge by SendMessage ("stage and commit by + pathspec with the task's commit line, nothing else"); still uncommitted → the + orchestrator commits by pathspec with that line (the one exception to "you edit no file"; + it commits, it does not edit) and lists the fold at the checkpoint; verification red → + a failed task for Phase 4. +- Phase 3: fast-path reviews run on `sonnet` whatever the implementer's model; deep reviews + stay on the other model of the pair (rewrite the model sentence at lines 114-115 and the + anti-pattern at line 180 so both say exactly that). A deep brief carries at most six numbered checks; + beyond that, split by file ownership into two reviewers. Every reviewer brief names a + report path and the 40-turn budget and asks for findings JSON only. +- Phase 3, the restated verdict schema (lines 136-141): add `"unchecked": ["..."]` so it + matches the agent file after T1; the cross-field PASS rule stays. +- Phase 4: replace the missing-verdict bullet with one line pointing at the stall ladder in + the orchestration rules; nudges and the one respawn consume no iteration. The fix fold + (lines 154-158) no longer offers `git commit --amend`: after parallel tasks HEAD is another + task's commit. A fix implementer commits `git commit --fixup= -- `; + once no agent is reading the tree, the orchestrator squashes with + `GIT_SEQUENCE_EDITOR=true git rebase --autosquash --no-autostash ~1` (git 2.44+; + the tree stays byte-identical), so the series keeps one commit per task. +- Phase 5: add "start `/cw:ship` in a fresh session". Anti-patterns: add "respawning a + stalled agent before its nudges". +- `skills/ship/SKILL.md`: after Step 6, one sentence: the next phase (`/cw:compound` or the + next design) starts in a fresh session. + +## Task T5 +- scope: Ship the notifier: `hooks/notify.sh`, its `hooks.json` bindings and 32 harness cases. +- files_owned: [hooks/notify.sh, hooks/hooks.json, tests/run.sh] +- files_forbidden: [hooks/protect-branches.sh, hooks/protect-secrets.sh, hooks/profile-check.sh, tests/dedupe-phrases.txt, tests/settings-keys.txt, tests/fixtures/, skills/, agents/, rules/, README.md, WORKFLOW.md, docs/, .claude-plugin/] +- depends_on: none +- agent: implementer +- model: sonnet +- risk: high +- verification: `cd "$(git rev-parse --show-toplevel)" && jq -e '.hooks.PreToolUse|length==2' hooks/hooks.json >/dev/null && jq -e '.hooks.Notification[0].matcher == "permission_prompt|worker_permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|push_notification|idle_prompt" and (.hooks.Stop|length)==1 and (.hooks.StopFailure|length)==1' hooks/hooks.json >/dev/null && shellcheck -x hooks/notify.sh tests/run.sh && bash tests/run.sh hooks | tee /dev/stderr | grep -Eq 'hooks: 1[4-9][0-9] passed, 0 failed' && /bin/bash tests/run.sh hooks | grep -Eq 'hooks: 1[4-9][0-9] passed, 0 failed' && ! grep -rn '' hooks/ tests/run.sh`, expected exit 0 +- commit: feat(hooks): session-labelled notifier on Notification, Stop and StopFailure with harness cases + +Specification — `hooks/notify.sh` (bash 3.2 clean, no `set -u`, `trap finish EXIT` with +`# shellcheck disable=SC2317,SC2329`, always exit 0, no output unless dry run): +1. Read `INPUT=$(cat)`. `command -v jq` missing → exit. `CW_NOTIFY=0` → exit. Parse the + string fields `hook_event_name`, `notification_type`, `message`, `session_id`, `cwd`, + `agent_id`, `agent_type` with `jq -r '.x // empty'`; `stop_hook_active` with + `jq -r '.stop_hook_active == true'`; the background count with + `jq '(.background_tasks // []) | length'` (never `-r ... // empty` on the array: an empty + array is truthy in jq and would print `[]`); a jq parse error → exit. +2. Config: `CW_NOTIFY_CONFIG` or `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cw-notify.json`. The + file is the opt-in: absent → exit; present but unparsable → defaults. Keys `enabled` + (true), `presence` (false), `idle_seconds` (300), `stale_seconds` (1800), `entrypoints` + (`["cli"]`), `idle_message` ("Waiting for your input"); every key optional. `enabled` + false → exit. +3. Headless guard: `CLAUDE_CODE_ENTRYPOINT` set and not in `entrypoints` → exit. +4. Dispatch on `hook_event_name`. State file: + `${CW_NOTIFY_STATE_DIR:-${TMPDIR:-/tmp}/cw-notify}/.bg`, one line + ` ` (`date +%s`, no `stat`). `Stop` → `mkdir -p` the directory, write + ` `, exit (a write failure is ignored). `StopFailure` → write + `0 `, exit. `Notification`: type must be one of the seven matcher types + (defensive re-check); `agent_id` non-empty → only `permission_prompt` and + `worker_permission_prompt` continue, message "Permission needed (agent: )". + Messages: `permission_prompt` → "Permission needed"; `worker_permission_prompt` → + "Permission needed (worker)"; `elicitation_dialog`/`elicitation_url_dialog` → "Input + needed"; `agent_needs_input`/`push_notification` → payload `message` or "Attention + needed"; `idle_prompt` → read the state file: missing or unparsable → ping + `idle_message`; count above zero and epoch younger than `stale_seconds` → exit; else + ping `idle_message`. Any other event → exit. +5. Title: folder = basename of payload `cwd` (else `$PWD`); registry + `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions/${CLAUDE_PID}.json` → `.name`; empty or + unreadable → title = folder; name starting with `-` → ` · `; else + ` · `. +6. Sanitise the message: CR/LF and control characters → spaces, squeeze, truncate to 200 + characters. Quotes and backslashes stay (argv delivery). +7. Presence (only when `presence` is true; runs on every OS so the override is testable + everywhere): state = `CW_NOTIFY_PRESENCE` when set to `at` or `away`; otherwise, on + Darwin only, probe idle = `ioreg -c IOHIDSystem` HIDIdleTime in seconds and locked = + `ioreg -n Root -d1 -a | plutil -extract IOConsoleUsers.0.CGSSessionScreenIsLocked raw -` + printing `true` or `1` (`plutil` prints booleans as `true`; the key is absent while + unlocked), away = idle ≥ `idle_seconds` or locked; on other systems or on any probe + error, state = at. Away and registry `.bridgeSessionId` non-empty → exit; otherwise + continue. Presence off → none of this runs. +8. Delivery: `CW_NOTIFY_DRY_RUN=1` → `printf 'notify: %s | %s\n' "$title" "$msg"`, exit 0. + Darwin → `osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' "$title" "$msg" >/dev/null 2>&1`, + guarding a leading `-` in either value: if `osascript -e 'on run argv' -e 'return item 1 of argv' -e 'end run' -- -x` + prints `-x`, pass `--` before the values; otherwise prefix a value that starts with `-` + with a space (checked once by the implementer, recorded in the exit report). Linux with + `notify-send` → `notify-send "$title" "$msg"`; else nothing. +- `hooks/hooks.json`: add `Notification` (the matcher above), `Stop` and `StopFailure` (no + matcher) entries, each `bash "${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh"`, `timeout: 5`. + Write to a temp file, `jq .` it, confirm the three existing entries are unchanged, then + move it in (Risk 10). +- `tests/run.sh`: `notify_case [ENV=VAL ...]` + runs the hook with `CW_NOTIFY_DRY_RUN=1 CW_NOTIFY_STATE_DIR=$SCRATCH/notify CLAUDE_CONFIG_DIR=$SCRATCH/cfg CLAUDE_PID=4242 CLAUDE_CODE_ENTRYPOINT=cli HOME=$SCRATCH/base` + plus the case's overrides, asserts exit 0 and, per ``, empty stdout or + stdout of exactly one line containing `` (`grep -F`, and `wc -l` = 1). Each + state-driven pair (a `Stop` or `StopFailure` then an `idle_prompt`) shares one + `session_id` of its own; every other case uses a fresh one. `hooks_notify_cases` writes + `$SCRATCH/cfg/cw-notify.json` as `{}` (the opt-in) and the fixture registry + `$SCRATCH/cfg/sessions/4242.json` with `jq -n` (`name` "proj-a0", `cwd` `$SCRATCH/proj`, + `bridgeSessionId` null), then runs, in this order: (1) agent_completed silent; (2) + auth_success silent; (3) quota_auto_resume_fired silent; (4) computer_use_enter silent; + (5) permission_prompt → `proj · a0 | Permission needed`; (6) permission_prompt with + agent_id/agent_type "reviewer" → `agent: reviewer`; (7) worker_permission_prompt, no + agent_id → `Permission needed (worker)`; (8) agent_needs_input with agent_id silent; (9) + agent_needs_input with message "Need a choice" → `Need a choice`; (10) push_notification + message with `"`, `\` and an embedded newline → one output line containing both halves; + (11) elicitation_dialog → `Input needed`; (12) Stop with `"background_tasks": ["x"]` → + silent and the state file's first field is `1`; (13) idle_prompt, same session → silent; + (14) Stop with `"background_tasks": []` → silent, first field `0`; (15) idle_prompt, same + session → `Waiting for your input`; (16) idle_prompt, fresh session, no state file → + output; (17) Stop with the `background_tasks` field absent → first field `0`; (18) + StopFailure, fresh session → silent, first field `0`, then idle_prompt → output; (19) + idle_prompt with a hand-written record `3 ` → output (stale = fail + open); (20) registry absent → title `proj |`; (21) registry name "mysession" → + `proj · mysession`; (22) config `{"presence":true}` + `CW_NOTIFY_PRESENCE=away` + registry + bridgeSessionId "b1" + idle_prompt with a `0` record → silent; (23) same without bridge → + output; (24) config `{}` + away + bridge → output (presence off by default); (25) + `CLAUDE_CODE_ENTRYPOINT=sdk-cli` silent; (26) `CW_NOTIFY=0` silent; (27) config file + absent → silent (opt-in); (28) config `{"enabled":false}` silent; (29) stdin `not json` → + exit 0, silent; (30) jq absent (PATH shim as in `hooks_jq_absent_case`) → exit 0, silent; + (31) `hooks/hooks.json` shape check with `jq -e` (the expression in this task's + verification); (32) `CW_NOTIFY_STATE_DIR` pointing at a regular file: Stop exits 0 + silently, then idle_prompt → output. Call `hooks_notify_cases` from `cmd_hooks` after + `hooks_profile_crlf_case`. Cases 22–24 run on every OS because the override is read before + any probe (step 7). +- By hand once, on the implementer's own session: create the config file, let a turn go + idle, and confirm one banner titled with the session name arrives about 60 s later; + record the result in the exit report. + +## Task T6 +- scope: Document 1.0.1: agents table, notifications section, loop description and the design-draft release note. +- files_owned: [README.md, WORKFLOW.md, docs/plans/cw-plugin/cw-plugin-design-draft.md] +- files_forbidden: [skills/, agents/, rules/, hooks/, tests/, .claude-plugin/, docs/plans/stall-handling-1-0-1.md, docs/plans/fixups-1-0-0.md, docs/plans/hardening-1-0-0.md] +- depends_on: [T1, T2, T3, T4, T5] +- agent: implementer +- model: sonnet +- risk: trivial +- verification: `cd "$(git rev-parse --show-toplevel)" && grep -qi 'cw-notify.json' README.md && grep -q 'idle_prompt' README.md && grep -q 'stall ladder' README.md && grep -Eq 'reviewer.*\b40\b' README.md && grep -qi 'Script Editor' README.md && grep -q 'worker_permission_prompt' README.md && [ "$(wc -l < WORKFLOW.md)" -le 200 ] && grep -q '1.0.1' docs/plans/cw-plugin/cw-plugin-design-draft.md && N=$(sed -n 's/^Copyright (c) [0-9]* //p' LICENSE | cut -d' ' -f1) && [ -n "$N" ] && ! grep -qi "$N" docs/plans/cw-plugin/cw-plugin-design-draft.md README.md WORKFLOW.md && ! grep -rn '' README.md WORKFLOW.md docs/plans/cw-plugin/`, expected exit 0 +- commit: docs: 1.0.1 notifier, turn caps, lean loop and stall ladder in README, WORKFLOW and the draft + +Specification: README agents table gains a turn-cap column (40/40/15/50/30) and drops any +memory mention; a "Notifications" section (≤ 40 lines): that the notifier is opt-in (create +`cw-notify.json`, `{}` is enough), which events ping at once and how the idle ping is gated +on the background count `Stop` records, the title format, the config keys with defaults, +the `CW_NOTIFY=0` kill switch, presence off by default and what turning it on does, why +`agent_completed` and the quota types are excluded, Linux behaviour, that on macOS 15+ the +banner is delivered as Script Editor and needs that +app allowed in System Settings › Notifications (practitioner-sourced: the command exits 0 +and shows nothing otherwise), and one paragraph on removing a pre-existing user-level +Notification hook (the plugin's replaces it; the operator edits their settings by hand). The loop description states two review rounds, the `lite` +flag, the ladder by pointer. `WORKFLOW.md` line ~102 and the review-loop paragraph reflect the +same. The design draft gains a "Release 1.0.1" section (≤ 40 lines): what changed and why, +with the measured numbers from Context. The two manifests are T7's. + +## Task T7 +- scope: Bump the plugin version to 1.0.1, add the marketplace description, and run the release gate on the finished series as this task's verification. +- files_owned: [.claude-plugin/plugin.json, .claude-plugin/marketplace.json] +- files_forbidden: [skills/, agents/, rules/, hooks/, tests/, README.md, WORKFLOW.md, docs/] +- depends_on: [T6] +- agent: implementer +- model: sonnet +- risk: trivial +- verification: `cd "$(git rev-parse --show-toplevel)" && grep -q '"version": "1.0.1"' .claude-plugin/plugin.json && jq -e '.description | length > 0' .claude-plugin/marketplace.json >/dev/null && jq empty .claude-plugin/plugin.json .claude-plugin/marketplace.json hooks/hooks.json && out=$(claude plugin validate . 2>&1) && ! printf '%s' "$out" | grep -qi warning && claude plugin validate .claude-plugin/plugin.json >/dev/null 2>&1 && CW_SCAN_PATTERNS="${CW_SCAN_PATTERNS:-$HOME/.claude/private/pii-patterns.txt}" bash tests/run.sh all && /bin/bash tests/run.sh hooks | grep -q ' 0 failed' && shellcheck -x hooks/*.sh tests/run.sh && { [ -x "$HOME/.claude/plans/cw-plugin-gates/shellcheck-0.9.0" ] && "$HOME/.claude/plans/cw-plugin-gates/shellcheck-0.9.0" -x hooks/*.sh tests/run.sh || echo "shellcheck 0.9.0 absent, skipped"; } && ! git grep -niE 'opus needs|do not trust|be decisive|read all files fresh|claude-design-active|claude-orchestrator-active' -- skills agents rules && ! git grep -nwE 'NEVER|ALWAYS|MUST|CRITICAL' -- skills agents rules && ! git grep -niE 'under 50 lines' -- skills agents rules && ! git grep -nE '^(disallowedTools|permissionMode|mcpServers|hooks):' -- agents && for f in agents/*.md; do for k in model tools effort maxTurns; do grep -q "^$k:" "$f" || exit 1; done; done && ! git grep -niE '~/.claude/plans|gitattributes|linguist|git clean' -- skills && grep -q haiku skills/design/SKILL.md && ! grep -rn '' skills agents rules hooks tests README.md WORKFLOW.md docs/plans/cw-plugin && N=$(sed -n 's/^Copyright (c) [0-9]* //p' LICENSE | cut -d' ' -f1) && [ -n "$N" ] && ! git grep -qi "$N" -- skills agents rules hooks tests WORKFLOW.md CLAUDE.md docs && [ "$(grep -E -c '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|@[a-z0-9.-]+\.[a-z]{2,}' docs/plans/stall-handling-1-0-1.md)" -eq 0 ] && [ "$(git log --format=%s main..HEAD | wc -l | tr -d ' ')" -eq 8 ] && [ "$(git log --format=%s main..HEAD | sort | uniq -d | wc -l | tr -d ' ')" -eq 0 ] && git diff --quiet && git diff --cached --quiet`, expected exit 0 +- commit: chore(release): version 1.0.1 and the marketplace description + +Specification: `plugin.json` `"version": "1.0.1"` only; `marketplace.json` gains a top-level +`"description"` (one sentence on what the marketplace offers) beside `name`, `owner` and +`plugins`. The `` residue grep excludes `docs/plans/*.md` on purpose: earlier plans +and this one quote the literal inside their own guards. Run the verification after the +commit; a failing clause is a finding for the checkpoint, not a reason to edit files this +task does not own. From 7615f23a718e6640886d490e9b3f387150c840b6 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:17:55 +0100 Subject: [PATCH 2/9] fix(agents): no reviewer memory, sized turn caps, unchecked lists and bounded reports Co-Authored-By: Claude --- agents/design-reviewer.md | 12 ++++++++++-- agents/direction-reviewer.md | 4 ++-- agents/implementer.md | 11 ++++++++--- agents/researcher.md | 17 +++++++---------- agents/reviewer.md | 23 ++++++++++++++++------- 5 files changed, 43 insertions(+), 24 deletions(-) diff --git a/agents/design-reviewer.md b/agents/design-reviewer.md index 2dfb919..a520abd 100644 --- a/agents/design-reviewer.md +++ b/agents/design-reviewer.md @@ -3,7 +3,7 @@ name: design-reviewer description: "Reviews a finalized design draft against the real codebase, current web best practices, and internal consistency. Returns a strict JSON verdict. Read-only — cannot modify files. Used by the /cw:design auto-review loop." model: opus effort: high -maxTurns: 25 +maxTurns: 40 tools: - Read - Glob @@ -15,7 +15,6 @@ tools: - mcp__context7__resolve-library-id - mcp__context7__query-docs - Skill -memory: project color: purple --- @@ -61,6 +60,14 @@ the prompt. - Only raise an issue you can back with evidence; if the design is sound, say PASS. +## Budget +Your turn budget is finite and stated in the prompt; you have no write tool, +so there is no report file — keep a running list of confirmed issues in your +own reasoning as you go. Emit the JSON verdict once every method step is +done, or once roughly two thirds of the budget is spent, whichever comes +first. A step you did not reach goes under `unchecked`, not silently +dropped. + ## Output — strict JSON only Emit exactly one JSON object as your final message, no prose around it: @@ -76,6 +83,7 @@ Emit exactly one JSON object as your final message, no prose around it: "evidence": "file:line, citation, or contradiction location" } ], + "unchecked": ["method step or claim not reached before the budget cutoff"], "suggested_fixes": ["concrete, ordered fixes the orchestrator can apply"] } ``` diff --git a/agents/direction-reviewer.md b/agents/direction-reviewer.md index 8508736..18c1286 100644 --- a/agents/direction-reviewer.md +++ b/agents/direction-reviewer.md @@ -8,7 +8,6 @@ tools: - Read - Glob - Grep -memory: project color: red --- @@ -57,7 +56,8 @@ Read both fresh. ## Output — strict JSON only -Emit exactly one JSON object as your final message, no prose around it: +Emit exactly one JSON object as your final message, no prose around it: the +JSON is the whole final message. ```json { diff --git a/agents/implementer.md b/agents/implementer.md index ebbeae6..6a553e6 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -26,8 +26,10 @@ scoped task from an orchestrator and execute it precisely. - Consult framework and library docs (via the context7 tools) before relying on an unfamiliar API surface. - Make one logical change per commit, in conventional commit format. -- Commit as instructed by the orchestrator: one commit for the task, carrying - whatever trailer the project's workflow rules define. +- As soon as `verification` passes, stage and commit by explicit pathspec + (`git add -- && git commit -- `) before any optional check, + tidy-up, or re-read. Carry whatever trailer the project's workflow rules + define. - If the design is ambiguous, make a reasonable choice and note it in your output — do not stop to ask. - Do not push commits or open pull requests; that is the orchestrator's job. @@ -35,7 +37,10 @@ scoped task from an orchestrator and execute it precisely. instead of touching it. ## Output -When done, provide a concise summary: +Write full working notes to the report file the prompt names, not to the +final message; the exit report is the last thing you write — do not spend +turns on notes or memory beyond it. Your final message is at most about ten +lines: 1. Files created or modified 2. Design deviations, if any, and why 3. Test and build results diff --git a/agents/researcher.md b/agents/researcher.md index 7dea110..f7fd24a 100644 --- a/agents/researcher.md +++ b/agents/researcher.md @@ -45,15 +45,12 @@ with the Skill tool rather than assuming its steps are already loaded. gap. ## Output format (required) +Your final message is at most about 1,500 tokens: numbered findings, no +narrative, no restated questions. -### Findings -- [Claim] — Source: [URL] ([publisher], [date if available]) — Confidence: high|medium|low +1. [Claim], 2-3 lines. Grade: H (official docs) | M (practitioner, with + numbers) | L (inference). Source: [one URL]. +2. ... -### Consensus -[strong|weak|conflicting] across [N] sources - -### Gaps -- [What could not be verified or found] - -### Recommendation -[One paragraph: what the evidence points to and why] +### Unsourced +- [Claim you could not back with a citation, stated plainly as a guess] diff --git a/agents/reviewer.md b/agents/reviewer.md index 95dff48..3ecf973 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -1,22 +1,22 @@ --- name: reviewer -description: "Code review agent. Reviews changes for quality, correctness, and convention adherence. Read-only — cannot modify files. Used by the /cw:build review loop." +description: "Code review agent. Reviews changes for quality, correctness, and convention adherence. No Write or Edit tool; Bash writes only the report file the prompt names, nothing else in the tree. Used by the /cw:build review loop." model: opus effort: high -maxTurns: 20 +maxTurns: 40 tools: - Read - Glob - Grep - Bash -memory: project color: yellow --- # Code Reviewer You review code changes against the project's conventions and the approved -design. You have no Write or Edit tool: you are read-only. +design. You have no Write or Edit tool; Bash writes only the report file the +prompt names, nothing else in the tree. ## Process 1. Read the project's CLAUDE.md for conventions. @@ -39,12 +39,21 @@ Every finding needs a `file:line` and either a failing command or a concrete, reproducible scenario. Drop a finding you cannot back with evidence rather than listing it anyway. +## Budget +Your turn budget is finite and stated in the prompt. Open the report file +the prompt names on your first turn and append each finding as you confirm +it (Bash `>>`), not in a batch at the end. Emit the final JSON verdict once +every check is done, or once roughly two thirds of the budget is spent — +whichever comes first. A check you did not reach goes under `unchecked`, not +silently dropped. No memory notes, no summary beyond the report file and the +final JSON. + ## Output — required structured verdict -Your final message must end with a fenced JSON block matching exactly this -schema: +Your final message is the fenced JSON block below plus one line naming the +report file, nothing else: ```json -{"verdict": "PASS|NEEDS_WORK", "findings": [{"file": "...", "line": 0, "issue": "...", "evidence": "..."}]} +{"verdict": "PASS|NEEDS_WORK", "findings": [{"file": "...", "line": 0, "issue": "...", "evidence": "..."}], "unchecked": ["..."]} ``` Start each finding's `issue` text with its severity: `critical` (wrong From 3805ffa3750200e9c2d8fb103e7bb75c36a14fc5 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:18:45 +0100 Subject: [PATCH 3/9] feat(rules): stall ladder as the one home, two-round review cap, lite design path Co-Authored-By: Claude --- rules/orchestration.md | 35 +++++++++++++++++++++++++++++------ rules/workflow.md | 10 ++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/rules/orchestration.md b/rules/orchestration.md index d008b07..f4b5bdd 100644 --- a/rules/orchestration.md +++ b/rules/orchestration.md @@ -31,20 +31,39 @@ - Ground truth over agent testimony: completion is git log plus actual test or build output, not a subagent's self-report. - Reviewers run with fresh context on a counter-model (not the implementer's), - framed adversarially, evidence per finding (file:line or a failing command). + framed adversarially, evidence per finding (file:line or a failing command); + a fast-path review (conventions and the commit only) may run on `sonnet` + whatever the implementer's model — a second model buys little there. - Tier review depth by risk: deep review for schema, API, auth and security work; fast-path the trivial tasks. -- NO_VERDICT (silence or an unparseable reply) is distinct from NEEDS_WORK: - retry once fresh, then escalate; do not burn iterations or deadlock on it. +- The stall ladder for NO_VERDICT (no parseable fenced JSON, or a partial + result after maxTurns) — distinct from NEEDS_WORK, and its one home: + - ground truth first: implementer — `git log` for the commit and the + verification command; reviewer — its report file, when it can write one + (a design reviewer has no write tool, JSON only). + - nudge 1, SendMessage to the same agent: "Make no further tool calls. + Your next message is the final report in the required JSON; list what + you did not reach under unchecked." + - nudge 2, harder: "No tool calls, no memory, no prose before the block." + - respawn once, fresh, briefed with the partial report and unchecked + list; built-in Explore/Plan agents cannot resume and skip straight here. + - escalate. Two nudges plus one respawn is the cap; nudges consume no + review iteration. + Implementer variant: committed and passing → review, noting the missing + report; uncommitted and passing → one commit nudge ("stage and commit by + pathspec, nothing else"), then the orchestrator commits by pathspec and + records the fold at checkpoint; failing → a failed task for Phase 4, + never a nudge. ## Review-loop anti-ratchet (code, design docs, prose, legal) - Reviewers output structured findings (location, evidence, severity, suggestion ≤25 words), not rewrites; deciding the fix is a role separate from finding. - Accept/drop decisions happen before redrafting, against explicit size and scope budgets; "accepted risk" is a legitimate outcome, logged not litigated. -- Cap adversarial review at one or two rounds: round-2+ findings are majority - false positives (measured); later rounds need a higher evidence bar, not a - fresh mandate to find something. +- Cap adversarial review at two rounds by default; a third only when round + two produced a verified critical finding; then escalate. Round-2+ findings + are majority false positives (measured); later rounds need a higher + evidence bar, not a fresh mandate to find something. - Termination, size and scope limits are checked mechanically (script or count), not self-declared by an agent. - "Be concise" prompts do nothing (measured ~zero effect); enforce brevity @@ -58,3 +77,7 @@ failed corrections, not at a token counter. - Re-inject standing governance rules (branch policy, commit format) at each phase start; compaction drops policies that are not the active goal. +- Every checkpoint tells the operator to start the next phase in a fresh + session. +- Every brief names a report file and asks for a return of at most about + 1,500 tokens; reviewers return findings JSON only. diff --git a/rules/workflow.md b/rules/workflow.md index b853511..ba59de7 100644 --- a/rules/workflow.md +++ b/rules/workflow.md @@ -12,9 +12,11 @@ routing and workflow usage follow the orchestration rules. Co-Authored-By: Claude - No implementation without an approved design. If uncertain, ask first. - Two human checkpoints, both non-negotiable: after the design, before the PR. -- `/cw:design` runs in plan mode; on approval the single plan file moves to the - project's `docs/plans/.md`, which is the canonical copy. In a folder - that is not a repo the file stays where plan mode put it. +- `/cw:design [lite] ` runs in plan mode; on approval the single + plan file moves to the project's `docs/plans/.md`, which is the + canonical copy (in a folder that is not a repo the file stays where plan + mode put it). `lite` is the last token, stripped before the slug is + derived: Explore only, one review round, no direction pass — for fix-ups. - `/cw:build [continue []]` reads the `## Task ` blocks of that file, commits the design as `docs(): design`, delegates every change to an implementer, and stops at the checkpoint before the PR. @@ -34,5 +36,5 @@ routing and workflow usage follow the orchestration rules. ## Context hygiene - Use subagents for tasks whose output would bloat the main context: exploration, review, large diffs. -- Suggest `/clear` between unrelated tasks. +- Suggest `/clear` between phases, not only between unrelated tasks. - For large features, suggest one session per phase. From 6e5503718e5f9a76676bd34cb12cdaee0e36d693 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:23:25 +0100 Subject: [PATCH 4/9] feat(design): lite path, two-round cap, synthesis research and bounded briefs Co-Authored-By: Claude --- skills/design/SKILL.md | 220 +++++++++++++++++++++-------------------- 1 file changed, 113 insertions(+), 107 deletions(-) diff --git a/skills/design/SKILL.md b/skills/design/SKILL.md index 031ce38..a6b6123 100644 --- a/skills/design/SKILL.md +++ b/skills/design/SKILL.md @@ -1,7 +1,7 @@ --- name: design -description: "Research-grounded feature design that ends in one approved plan file. Use when the user says /cw:design, starts planning a feature, asks how to approach something, or needs an implementation plan. Challenges the premise first, grounds the design in the real codebase and current web sources, self-reviews until findings converge (escalating after 5 iterations), then stops for the user's approval before any code is written." -argument-hint: "" +description: "Research-grounded feature design ending in one approved plan file for /cw:design, planning a feature, asking how to approach something, or an implementation plan; two review rounds by default (lite: one), then stops for the user's approval before any code is written." +argument-hint: " [lite]" effort: xhigh allowed-tools: - Read @@ -18,20 +18,21 @@ allowed-tools: # Research-Grounded Design -The cost model is 80/20: spend the effort in planning and review, not in -redrafting later. Research first, challenge the premise, draft, self-review to -convergence, then stop for the user's approval. +The cost model is 80/20: put the effort into planning and review, not redrafting later. +Research first, challenge the premise, draft, self-review to convergence, then stop for +approval. ## Input $ARGUMENTS -Derive a short kebab-case `` (first 3–5 meaningful words). One feature -produces **one file**: the plan. Steps 0–5 run in plan mode and write it -wherever plan mode puts plan files — `docs/plans/` when the profile sets -`plansDirectory: docs/plans`, otherwise the default plans location. Step 5 ends -with this skill calling `ExitPlanMode` itself, the explicit mode transition; -approval there is administrative, ending drafting so Step 6 can move the file. -The real accept/reject decision is `/cw:build`. +If the last token is the word `lite`, strip it and run the lite path (fewer subagents in +Step 2, fewer review rounds by default in Step 4). Derive a short kebab-case `` +from what remains (first 3-5 meaningful words). One feature produces **one file**: the +plan. Steps 0-5 run in plan mode and write it wherever plan mode puts it — `docs/plans/` +when the profile sets `plansDirectory: docs/plans`, otherwise the default plans +location. Step 5 ends with this skill calling `ExitPlanMode` itself; approval there is +administrative, ending drafting so Step 6 can move the file. The real accept/reject +decision is `/cw:build`. ## Step 0: Office-hours gate (before any research spend) Pose one adversarial challenge and wait for an answer: @@ -40,77 +41,78 @@ Pose one adversarial challenge and wait for an answer: > cheapest way to invalidate the premise? Is there an existing solution that > already covers it? -Adopt a redirect if one comes; "skip — already validated" is a fine answer. -Do not spend Step 2's budget before this is answered. +Recommend `lite` when the brief is a fix-up: research already exists, the change touches +3 files or fewer, and no new subsystem is involved. Adopt a redirect if one comes; "skip +— already validated" is fine. The operator's answer decides either way. Do not spend +Step 2's budget before this is answered. ## Step 1: Understand (main thread) 1. Read the project's CLAUDE.md, ARCHITECTURE.md or equivalent for conventions. 2. `git log --oneline -20` on the paths the feature touches — recent intent the - source alone will not show. +source alone will not show. 3. Read the relevant source files to see what already exists. 4. List the unknowns and assumptions. Ask the user about an ambiguous spec - rather than guessing. +rather than guessing. +5. Record `architectural: yes|no` — yes when the design introduces a new +subsystem, a public interface or schema, or changes how the loop itself works. This +gates Step 4b later, independent of the lite/full choice. ## Step 2: Parallel grounding (synchronous) -Spawn three subagents together, three Agent calls in one message — not in the -background, not one at a time: - -- **`Explore`** (the built-in agent, this call's `model` pinned to `haiku`) — - map what the feature touches: existing patterns, the modules, schemas and - functions it will reference, where similar things already live. Fan-out - navigation is the haiku tier; do not let it inherit the orchestrator model. - If its summary is too thin to ground the design, re-spawn that one call with - `sonnet`. -- **`cw:researcher`** #1, docs and API currency — "for the libraries and - frameworks likely involved (name them), verify the current API surface and - that nothing this design relies on is deprecated." Tell it to prioritise - context7. -- **`cw:researcher`** #2, best practices — "for the architectural choices this - feature implies, what is current best practice, and what newer pattern might - supersede the obvious one?" Tell it to lean on the practitioner and - contrarian angles. Invoke `/cw:search` through the Skill tool when you want - its full method in the main thread. - -Subagents are read-only. Fold each returned summary into the plan's Research -section verbatim enough to stand alone — the review loop reads it there with -fresh context. Every architectural claim traces to that section or carries an -explicit "unsourced — judgment call" flag. - -Grounding that needs more than four angles (a large feature spanning several -subsystems) runs as one read-only ultracode workflow instead of more researcher -agents, writing its synthesis into the same section. +Full path spawns three subagents together, in one message, not in the background: `Explore` +plus both researchers. Lite spawns `Explore` alone, adding one researcher only for the +unknown Step 1 named. + +- **`Explore`** (built-in, `model` pinned to `haiku`; routing per +`rules/orchestration.md`) — map what the feature touches: existing patterns, modules, +schemas and functions it will reference. Re-spawn with `sonnet` if too thin. +- **`cw:researcher`** #1, docs and API currency — libraries and frameworks +likely involved (name them), verify the current API surface and that nothing this design +relies on is deprecated. Prioritise context7. +- **`cw:researcher`** #2, best practices — for the architectural choices this +feature implies, current best practice and what newer pattern might supersede the +obvious one. Lean on practitioner and contrarian angles. + +Every brief states the turn budget and caps the return at about 1500 tokens of +findings and citations, not a transcript; an agent that can write (Explore, via +Bash) also gets a report path under the session scratchpad; the researcher and +the reviewers return findings in the message. Run +`/cw:search` via the Skill tool when you want its full method in the main thread. +More than four angles run as one read-only ultracode workflow, not more researchers. ## Step 3: Write the plan file -`.md`, in this order: +`.md`, capped at 400 lines — a feature that needs more is two features. Sections, +in this order: - **Context** — the problem, why now, the intended outcome. - **Approach** — what to build and why this over the alternatives, citing - Research. +Research. - **Files to create/modify** — exact paths with purpose, referencing the - patterns Step 2 found. +patterns Step 2 found. - **Dependencies** — new packages, keys or services, version-pinned. - **Risks & mitigations** — what can go wrong, how it is detected, how it rolls - back. +back. - **Success criteria** — testable assertions. -- **Research** — the Step 2 summaries: codebase, docs/API, best practices. +- **Research** — a synthesis of each Step 2 report, at most about 25 lines per +angle: the findings that actually shaped a decision, each with a grade and a URL. Do not +paste a report verbatim; every architectural claim traces here or carries an explicit +"unsourced — judgment call" flag. - **Tasks** — the task list, always last. Introduce it with a level-1 `# Tasks` - heading or a plain sentence: a `## Tasks` heading would be miscounted as a - task by `/cw:build`, which collects every `## Task ` block in the file. +heading or a plain sentence: a `## Tasks` heading would be miscounted as a task by +`/cw:build`, which collects every `## Task ` block in the file. ## Step 3b: The task list -Derive it from "Files to create/modify" plus the approach; this is what -`/cw:build` executes, so each block is precise and self-contained. +Derive it from "Files to create/modify" plus the approach; this is what `/cw:build` +executes, so each block is precise and self-contained. -The count is adaptive — one task for a trivial change, as many as the design -implies for a large one. There is no target range: do not pad to a number or -cap at one. Split so no two tasks own the same file; disjoint `files_owned` is -what prevents conflicts and lets independent tasks run together. Each task maps -to exactly one atomic Conventional commit. +The count is adaptive — one for a trivial change, as many as a large one implies; do not +pad to a number. More than eight signals splitting the feature instead. Split so no two +tasks own the same file; disjoint `files_owned` prevents conflicts and lets independent +tasks run together. Each task maps to exactly one atomic Conventional commit. -When the feature spans layers, order tasks as vertical slices — -schema→API→UI→test for slice 1, then the same for slice 2 — rather than every -schema task before any API task. Each slice ends at its own verification gate, -and a failure there blocks only that slice's downstream tasks. +When the feature spans layers, order tasks as vertical slices — schema→API→UI→test for +slice 1, then the same for slice 2 — rather than every schema task before any API task. +Each slice ends at its own verification gate, and a failure there blocks only that +slice's downstream tasks. ``` ## Task @@ -119,74 +121,78 @@ and a failure there blocks only that slice's downstream tasks. - files_forbidden: [other tasks' files_owned, plus anything out of scope] - depends_on: [task ids | none] - agent: implementer -- model: sonnet | opus # opus for >5 files, long-horizon, or cross-cutting schema +- model: sonnet | opus # routing: rules/orchestration.md - risk: high | normal | trivial # feeds the review depth /cw:build applies - verification: exact command(s), expected exit 0 # the acceptance contract, never narrative - commit: (): | none # `none` = unversioned/external target, verification-only ``` ## Step 4: Convergence review loop (synchronous) -Counter starts at 1; loop until convergence or the safety valve fires. +Full path: two rounds by default, a third only when round two produced a verified +critical finding. Lite path: one round by default, a second only on a verified critical +finding. Reviewer briefs match Step 2's turn budget and return cap; findings return as +JSON in the message — reviewers have no write tool, no report path. -1. Spawn a fresh **`cw:design-reviewer`** on the plan file. Fresh context every - pass is the point — never feed a prior iteration's findings into the next - reviewer's prompt. +1. Spawn a fresh **`cw:design-reviewer`** on the plan file — never feed a prior round's +findings into the next reviewer's prompt. 2. **Verify every critical finding before acting on it.** A file:line claim: - read that file yourself and confirm it says what the finding says. A - citation claim: check it against the plan's Research section; unbacked means - unverified. A finding you cannot verify is logged as an unverified risk and - carried to Step 5 — not acted on, and not blocking convergence. +read that file yourself and confirm it says what the finding says. A citation claim: +check it against the plan's Research section; unbacked means unverified. A finding you +cannot verify is logged as an unverified risk and carried to Step 5 — not acted on, and +not blocking convergence. 3. Revise the plan (and the task list, when boundaries move) from the verified - critical findings only. Minor findings accumulate as notes for Step 5 and - are never actioned mid-loop. -4. Re-review with a brand-new reviewer instance against the revised plan. - -**Exit**: an iteration producing zero verified critical findings has converged. -**Unparseable reply**: a reviewer that returns no verdict, or one that cannot -be parsed, is not a NEEDS_WORK — retry that reviewer once, fresh, then escalate -rather than burning loop iterations. -**Safety valve**: after 5 iterations without convergence, stop and escalate -with the surviving verified critical findings listed. No sixth iteration, no -silent finalisation. - -## Step 4b: Direction pass (once) -After convergence, run **`cw:direction-reviewer`** once on the original -`$ARGUMENTS` plus the finished plan. Its `REDIRECT` is a decision for the user, -not an auto-fix: fold it in when its reframe cites concrete evidence, otherwise -surface it at the checkpoint. It does not loop and does not block on its own. +critical findings only. Minor findings accumulate as notes for Step 5 and are never +actioned mid-round. +4. Zero verified critical findings: stop, converged. Otherwise spawn a +brand-new reviewer for the next round the caps allow — full path: round 2's verified +critical finding opens round 3; lite: round 1's opens round 2. A verified critical +finding surviving that final round escalates to the user at Step 5 with the surviving +findings, not looped further. + +**Missing or unparseable verdict**: follow the stall ladder in `rules/orchestration.md`, +not a round burned retrying blind. + +## Step 4b: Direction pass (once, architectural only) +When Step 1 recorded `architectural: yes`, run **`cw:direction-reviewer`** once on the +original `$ARGUMENTS` plus the finished plan — full or lite path alike; skip it +otherwise. Bound its turn budget and return cap like Step 4's briefs, findings only. +Its `REDIRECT` is a decision for the user, not an +auto-fix: fold it in when its reframe cites concrete evidence, otherwise surface it at +the checkpoint. It does not loop and does not block on its own. ## Step 5: Summary, then `ExitPlanMode` -Present the plan, the loop result ([N] iterations, converged or escalated with -M surviving findings), the minor-note and unverified-risk counts, the direction -verdict, and the task count. Then call `ExitPlanMode`, noting that approving -there moves the file into the project and that `/cw:build` is still the real -decision point. On rejection, stay in plan mode and follow the iteration -protocol; on approval, continue straight into Step 6 without a new turn. +Present the plan, path taken (full or lite), rounds used and result (converged or +escalated with the surviving findings), minor-note and unverified-risk counts, +direction verdict, and task count. Then call `ExitPlanMode`, noting approval there moves the file into the +project and `/cw:build` is still the real decision point. On rejection, stay in plan +mode and follow the iteration protocol; on approval, continue straight into Step 6, +without a new turn. ## Step 6: Move the plan into the project 1. `ROOT=$(git rev-parse --show-toplevel 2>/dev/null)`. Empty (not a repo) → - leave the file where plan mode put it, say so, and go to Step 7. +leave the file where plan mode put it, say so, and go to Step 7. 2. `mkdir -p "$ROOT/docs/plans"`, then move the file to - `$ROOT/docs/plans/.md` — `git mv` when plan mode's copy is already - tracked, plain `mv` otherwise. -3. `git -C "$ROOT" check-ignore -q docs/plans/.md`. If the destination is - ignored, print the fix by name rather than a bare warning: +`$ROOT/docs/plans/.md` — `git mv` when plan mode's copy is already tracked, plain +`mv` otherwise. +3. `git -C "$ROOT" check-ignore -q docs/plans/.md`. If ignored, name the +fix rather than a bare warning: > the design file is ignored by .gitignore; un-ignore docs/plans/.md before /cw:build ## Step 7: Checkpoint (the one stop) Print the final path verbatim and stop: -> "Plan written to `[path]`. `/cw:build ` is the real decision point — -> exiting plan mode above was provisional. The file is untracked until -> `/cw:build` commits it, so avoid a bulk `git add .` in the meantime. What -> would you like to adjust before I implement?" +> "Plan written to `[path]`. Start `/cw:build ` in a fresh session — the +> real decision point; exiting plan mode above was provisional. The file is +> untracked until `/cw:build` commits it, so avoid a bulk `git add .` in the +> meantime. What would you like to adjust before I implement?" ## Iteration protocol -Feedback at either stop follows one path, against whichever copy of the plan -exists then (plan mode's before Step 6, the project's after): +Feedback at either stop follows one path, against whichever copy of the plan exists then +(plan mode's before Step 6, the project's after): -1. Spawn a `cw:researcher` for any new unknown and update the Research section. +1. Spawn a `cw:researcher` for any new unknown, turn-budgeted with Step 2's +findings-only cap; update the Research section. 2. Revise the plan, then regenerate the task list so it never lags the prose. -3. Re-run Steps 4 and 4b on the revised plan. +3. Re-run Steps 4 and 4b on the revised plan, same round caps as before. 4. Return to the checkpoint you came from and stop again, until the user says - to proceed. +to proceed. From 55e39d829d97255a8f3cf8a6873b71cf5f96eb08 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:23:26 +0100 Subject: [PATCH 5/9] feat(build): bounded briefs, commit by pathspec, stranded-implementer gate and the ladder pointer Co-Authored-By: Claude --- skills/build/SKILL.md | 172 ++++++++++++++++++++++-------------------- skills/ship/SKILL.md | 3 +- 2 files changed, 93 insertions(+), 82 deletions(-) diff --git a/skills/build/SKILL.md b/skills/build/SKILL.md index fa16e53..1069b99 100644 --- a/skills/build/SKILL.md +++ b/skills/build/SKILL.md @@ -1,6 +1,6 @@ --- name: build -description: "Implement an approved design by orchestrating subagents. Consumes the plan's task list, delegates each task to an implementer, verifies the result against git and the task's own verification command, reviews with a second model, and stops at a checkpoint before any PR." +description: "Implement an approved design via subagents: delegates each task to an implementer, verifies against git and the task's verification command, reviews with a second model, and stops at a checkpoint before any PR." disable-model-invocation: true argument-hint: " [continue []]" allowed-tools: @@ -24,37 +24,21 @@ allowed-tools: # Build — Orchestrator Pattern You are the orchestrator: you edit no file, and every change in this run is an -implementer's — the review gate is what enforces that. You do not re-decompose -the design either; `/cw:design` already wrote the task list. Consume it, -delegate, verify, review, iterate, stop. +implementer's, enforced by the review gate. You do not re-decompose the +design either; `/cw:design` wrote the task list. Consume it, delegate, +verify, review, iterate, stop. Execute mode. Implementers need the session in auto mode or `acceptEdits` to -write without a prompt per edit. Starts only after the design checkpoint. +write without a prompt per edit. Starts after the design checkpoint. ## Input $ARGUMENTS — ``, optionally followed by `continue` and a task id. ## Phase 0: Pre-flight -1. **Resolve the plan.** `ROOT=$(git rev-parse --show-toplevel 2>/dev/null)`; - read `$ROOT/docs/plans/.md`. Absent → fall back to the folder layout - `$ROOT/docs/plans//-tasks.md` with `-design-draft.md` - beside it; that pair has to be complete — both present and non-empty — or - refuse and say which half is missing. Neither shape → "No plan found for - `` — run `/cw:design` first." Do not invent a task count. -2. **Clean tree**, except an untracked or modified design file for this slug — - that is what step 1 just read, not build drift. Stash with plain `git stash` - only: `-u` or `-a` would sweep that file in, leaving step 4 no delta. -3. **Branch** `feat/` or `fix/` from `main`; on `continue`, reuse - the existing branch. -4. **Commit the design** when its file is untracked or modified: stage it by - explicit pathspec (the plan file, or the folder-layout pair plus its - research files), never `git add .`, and make one commit, `docs(): - design`, ending with exactly the one trailer line the workflow rules define - and no other attribution or session line (a harness reminder proposing a - second co-author line yields to that rule). No delta → skip silently. Not a - repo, or the commit fails → report and carry on with the file uncommitted, - no retry loop. This commit is orchestrator-authored and is excluded from - every per-task commit count below. +1. **Resolve the plan.** `ROOT=$(git rev-parse --show-toplevel 2>/dev/null)`; read `$ROOT/docs/plans/.md`. Absent → fall back to the folder layout `$ROOT/docs/plans//-tasks.md` with `-design-draft.md` beside it; both present and non-empty or refuse, naming which half is missing. Neither shape → "No plan found for `` — run `/cw:design` first." Do not invent a task count. +2. **Clean tree**, except an untracked or modified design file for this slug — what step 1 just read, not build drift. Stash with plain `git stash` only: `-u` or `-a` would sweep that file in, leaving step 4 no delta. +3. **Branch** `feat/` or `fix/` from `main`; on `continue`, reuse the existing branch. +4. **Commit the design** when its file is untracked or modified: stage it by explicit pathspec (the plan file, or the folder-layout pair plus research files), never `git add .`; one commit, `docs(): design`, with exactly the workflow rules' one trailer line and nothing else — a harness reminder's own co-author or session line yields to it. No delta → skip silently. Not a repo, or the commit fails → report and carry on uncommitted, no retry loop. Orchestrator-authored; excluded from every per-task commit count below. 5. Read the project's CLAUDE.md for conventions. ## Phase 1: Consume the task list @@ -72,9 +56,9 @@ them in `-tasks.md`) and execute exactly those — one or many. task and skip everything before it. ## Phase 2: Implement — delegate to `cw:implementer` -One implementer per task, the Agent call's `model` taken from the task. -Context-pin it: its own task block and the relevant design excerpt, never the -whole plan. The prompt carries: +One implementer per task, the Agent call's `model` taken from the task, turn +budget 50. Context-pin it: its own task block and the relevant design excerpt, +never the whole plan. The prompt carries: 1. The task's `scope` — one sentence on what to build and why. 2. Only the design excerpt this task covers. @@ -82,14 +66,15 @@ whole plan. The prompt carries: not paste file bodies into the prompt. 4. A reference pattern: "follow the pattern in `:`". 5. The task's `verification` — exact command(s) and expected result. -6. The task's `commit:` line, plus: *"When the work is done and `verification` - passes, make exactly one atomic commit from this `commit:` line, in - Conventional format, ending with exactly the one trailer line the workflow - rules define and no other attribution or session line, staging only - `files_owned` by explicit path. One task, one commit. Do not push."* Omit - this entirely for a `commit: none` task (an unversioned or external target) - — that task is verification-only. -7. A required exit report: its final message ends with a fenced JSON block +6. A report file path under the session scratchpad, the 50-turn budget, and + "return at most about 1,500 tokens in your final message." +7. The task's `commit:` line, plus: *"When `verification` passes, make exactly + one atomic commit from this `commit:` line, Conventional format, ending + with the one trailer line the workflow rules define, no other attribution + or session line. `git add -- && git commit -- ` + — never a bare `git commit`, the index is shared. One task, one commit. Do + not push."* Omit for a `commit: none` task — verification-only. +8. A required exit report: its final message ends with a fenced JSON block matching exactly this schema: ```json {"status": "success|partial|failed", "commit_sha": "", "files_touched": ["..."], "verification_output": "...", "unresolved_issues": ["..."]} @@ -97,87 +82,112 @@ whole plan. The prompt carries: The report is testimony, not proof; the gate below checks it. ## Phase 2→3 gate: check the report against the repo -Completion is what git and real command output show, not what a subagent says. -Before any reviewer is spawned: +Completion is what git and real command output show, not what a subagent +says. A missing or partial exit report is not itself a failure — ground +truth decides. Before any reviewer is spawned: - **`commit: none` task** — re-run its `verification` yourself and compare the - real output with the reported `verification_output`. No commit to check. -- **Any other task** — in `git log --oneline main..HEAD` (or the task's - expected range), confirm `commit_sha` exists, is Conventional-format, and - touches only `files_owned`; then re-run `verification` yourself and compare. -- `status != "success"`, a missing commit, a verification that actually fails, - or files touched outside `files_owned` → the task failed: fold it into Phase - 4's iteration logic instead of reviewing it. + real output with any reported `verification_output`. No commit to check. +- **Any other task**, read off the tree. Ground truth decides in this + order: + - Commit present (Conventional, or a `fixup!` of the task's sha awaiting + squash — see Phase 4), touching only `files_owned`, `verification` + re-run green → review it, noting a missing exit report rather than + failing it. + - No commit, `verification` re-run green, nothing outside `files_owned` → + one commit nudge by SendMessage: "stage and commit by pathspec with the + task's commit line, nothing else." Still uncommitted after the nudge → + the orchestrator commits by pathspec with that line itself — the one + exception to "you edit no file"; it commits, not edits — and lists + the fold at the checkpoint. + - `verification` red, files outside `files_owned`, or a malformed subject + → failed task, folded into Phase 4 rather than reviewed; the + out-of-scope check applies before the orchestrator commits by pathspec + too. - Phase 0's `docs(): design` commit may sit in the range; exclude it. ## Phase 3: Review — delegate to `cw:reviewer` -Only once the gate passes, and on the other model of the pair: `opus` reviews -what a `sonnet` implementer wrote, `sonnet` reviews what an `opus` one wrote. +Only once the gate passes. Fast-path runs on `sonnet` whatever the +implementer's model; deep review runs on the pair's other model: `opus` +reviews `sonnet`'s work, `sonnet` reviews `opus`'s. **Depth from `risk:`** — deep review when `risk: high`, or the task touches schema, API, auth or security, or `files_owned` spans more than three files: full checklist, adversarial framing, every finding reproduced. Otherwise -fast-path: conventions and the commit check only. - -The prompt carries the confirmed list of changed files; the design requirements -for them; adversarial framing ("your job is to find problems, not to approve — -the implementer's exit report is not evidence of anything"); "read the files -fresh from disk, run `git diff`, run the tests"; and reproduce-before-report -("every finding needs a file:line plus a failing command or a specific -reproducible scenario; drop what you cannot back"). +fast-path: conventions and the commit check only. A deep-review brief carries +at most six numbered checks; beyond that, split the review by file ownership +into two reviewers instead. + +The prompt carries the confirmed changed-file list; the design requirements +for them; adversarial framing ("find problems, don't approve — the exit +report is not evidence"); "read the files fresh from disk, run `git diff`, +run the tests"; reproduce-before-report ("every finding needs a file:line +plus a failing command or reproducible scenario; drop what you cannot +back"); a report file path under the session scratchpad and the 40-turn +budget; and "return findings JSON only." Commit check: "confirm the task produced exactly one commit in `git log ---oneline main..HEAD`, Conventional format, with exactly the project's one -trailer line (a second co-author line or a session line is malformed), touching -only `files_owned`; flag a missing, squashed or malformed one, and exclude the -`docs(): design` commit, which is not a task commit." Skip it entirely +--oneline main..HEAD`, Conventional format (a `fixup!` awaiting squash counts +with its target, not malformed — Phase 4), with exactly the project's one +trailer line (a second co-author line or a session line is malformed), +touching only `files_owned`; flag anything else missing, squashed or +malformed, and exclude the `docs(): design` commit." Skip it entirely for a `commit: none` task. Required verdict, as `agents/reviewer.md` defines it — the reviewer's final message ends with a fenced JSON block matching exactly this schema: ```json -{"verdict": "PASS|NEEDS_WORK", "findings": [{"file": "...", "line": 0, "issue": "...", "evidence": "..."}]} +{"verdict": "PASS|NEEDS_WORK", "findings": [{"file": "...", "line": 0, "issue": "...", "evidence": "..."}], "unchecked": ["..."]} ``` -Reviewers report every finding they can evidence; filtering by severity is your -pass, not theirs. Cross-field rule you enforce: `PASS` cannot co-occur with a +Reviewers report every finding they can evidence; filtering by severity is +yours, not theirs. A check the reviewer did not reach lands in `unchecked`, not +silently dropped. Cross-field rule you enforce: `PASS` cannot co-occur with a critical finding — that combination is a malformed verdict, so reprompt or escalate rather than accept it. ## Phase 4: Iterate -- **Missing or unparseable verdict** (no fenced block, unreadable JSON, a - silent reviewer) is not a NEEDS_WORK: spawn one fresh reviewer on the same - task and retry once, then escalate if it is no better. This retry does not - consume one of the three iterations below. +- **A missing or unparseable verdict** follows the stall ladder in + `rules/orchestration.md`; its nudges and one respawn consume no + iteration. - **PASS** → next task, then the checkpoint. - **NEEDS_WORK, iterations 1–2** → a new implementer gets the reviewer's - file:line findings and the original requirements: "fix exactly these issues, - do not refactor beyond them, and fold the fix into this task's existing - commit (`git commit --amend` or an autosquash fixup), so the series keeps - one atomic commit per task." For a `commit: none` task instead: "re-edit the - owned files only; do not run `git commit` or `git commit --amend` — this - task has no commit of its own, and amending would rewrite unrelated history - on the target." Then re-run the reviewer. -- **NEEDS_WORK, iteration 3** → stop and escalate with what was built, what was - flagged, what was attempted, and "three review iterations did not resolve + file:line findings and the original requirements: "fix exactly these + issues, do not refactor beyond them, then commit the fix separately: + `git add -- && git commit --fixup= -- `." + Once no agent is reading the tree, the orchestrator squashes each fixup with + `GIT_SEQUENCE_EDITOR=true git rebase --autosquash --no-autostash ~1` (git 2.44+), so the series keeps one commit per task; until then, a + `fixup!` commit awaiting the squash counts with its target in the commit + check. For a `commit: none` task instead: + re-edit the owned files only — no commit, no amend; this task has no commit + of its own, and amending would rewrite unrelated history on the target. + Then re-run the reviewer. +- **NEEDS_WORK, iteration 3** → stop and escalate with what was built, flagged + and attempted, and "three review iterations did not resolve these issues. How would you like to proceed?" After two failed corrections the problem is usually the design, not the code. ## Phase 5: Checkpoint Summarise: what was built; the `git log --oneline main..HEAD` series -(`commit: none` tasks contribute nothing there, as expected); design deviations +(`commit: none` tasks contribute nothing there); design deviations and why; final verdicts and remaining nits; test results; what to test by hand. Then: "Review the changes. Resume an unfinished run with `/cw:build -continue []`, or run `/cw:ship` when you are ready for the PR." Do not -open a PR here. +continue []`, or start a fresh session and run `/cw:ship` when you +are ready for the PR." Do not open a PR here. ## Anti-patterns - Editing a file yourself instead of spawning an implementer. - Re-decomposing the design, or padding it to a task count. -- A squashed or catch-all commit; each task self-commits. +- A squashed or catch-all commit; each task self-commits, a fix folds in by + fixup, not hand-edited history. - Passing the whole plan, or file contents, to an implementer. -- Reviewing before the gate passes, or on the implementer's own model. +- Reviewing before the gate passes, or off Phase 3's model rule (fast-path on + `sonnet` whatever the implementer's model, deep review on a model other + than the implementer's). - Running the fix loop past three iterations, or fixing findings yourself. -- `git add .`, or `git add -f` past a project's own `.gitignore`. +- `git add .`, `git add -f` past a project's own `.gitignore`, or a bare + `git commit` — pathspec only, the index is shared. - Amending for a `commit: none` task; re-edit its owned files instead. +- Respawning a stalled agent before its nudges. diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index 32723c7..63e1b9f 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -113,7 +113,8 @@ Ask once, after the URL: > rules files? `/cw:compound` proposes edits for your approval and keeps them > out of this PR." -Hand off if the user agrees; otherwise end here. +Hand off if the user agrees; otherwise end here. Start `/cw:compound` or the +next design in a fresh session — compaction drops policy rules mid-window. ## Safety rules - Do not push directly to main or master, and do not force-push any branch. From 7b5482f9af65337a249f6c81444177f1038c59d8 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:25:07 +0100 Subject: [PATCH 6/9] feat(hooks): session-labelled notifier on Notification, Stop and StopFailure with harness cases Co-Authored-By: Claude --- hooks/hooks.json | 34 +++++ hooks/notify.sh | 248 ++++++++++++++++++++++++++++++++++++ tests/run.sh | 324 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 606 insertions(+) create mode 100755 hooks/notify.sh diff --git a/hooks/hooks.json b/hooks/hooks.json index bb9eb6d..005a74b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -33,6 +33,40 @@ } ] } + ], + "Notification": [ + { + "matcher": "permission_prompt|worker_permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|push_notification|idle_prompt", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh\"", + "timeout": 5 + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh\"", + "timeout": 5 + } + ] + } ] } } diff --git a/hooks/notify.sh b/hooks/notify.sh new file mode 100755 index 0000000..01ce423 --- /dev/null +++ b/hooks/notify.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# Notification|Stop|StopFailure — session-labelled desktop notifier. Pings +# only when a human is needed: the four immediate Notification types, plus +# the built-in idle_prompt gated on the background-task count that Stop +# records. Opt-in via ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cw-notify.json +# (absent -> silent). Always exits 0: exit 2 on Stop would block the turn. +# No output unless CW_NOTIFY_DRY_RUN=1. + +# shellcheck disable=SC2317,SC2329 # invoked indirectly via `trap ... EXIT` below; older shellcheck reports SC2317, newer SC2329 +finish() { + exit 0 +} +trap finish EXIT + +INPUT=$(cat 2>/dev/null) + +command -v jq >/dev/null 2>&1 || exit 0 +[ "$CW_NOTIFY" = "0" ] && exit 0 + +printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1 || exit 0 + +HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty' 2>/dev/null) +NOTIF_TYPE=$(printf '%s' "$INPUT" | jq -r '.notification_type // empty' 2>/dev/null) +MESSAGE=$(printf '%s' "$INPUT" | jq -r '.message // empty' 2>/dev/null) +SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) +CWD_IN=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) +AGENT_ID=$(printf '%s' "$INPUT" | jq -r '.agent_id // empty' 2>/dev/null) +AGENT_TYPE=$(printf '%s' "$INPUT" | jq -r '.agent_type // empty' 2>/dev/null) +# shellcheck disable=SC2034 # parsed for parity with the spec's field list; not yet branched on +STOP_HOOK_ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active == true' 2>/dev/null) +BG_COUNT=$(printf '%s' "$INPUT" | jq '(.background_tasks // []) | if type == "array" then length else 0 end' 2>/dev/null) +case "$BG_COUNT" in ''|*[!0-9]*) BG_COUNT=0 ;; esac + +# --- config: the file's presence is the opt-in ------------------------------ +HOME_DIR="${HOME:-/root}" +CONFIG_FILE="${CW_NOTIFY_CONFIG:-${CLAUDE_CONFIG_DIR:-$HOME_DIR/.claude}/cw-notify.json}" +[ -f "$CONFIG_FILE" ] || exit 0 + +ENABLED=true +PRESENCE=false +IDLE_SECONDS=300 +STALE_SECONDS=1800 +IDLE_MESSAGE="Waiting for your input" +ENTRYPOINTS="cli" + +CFG_CONTENT=$(cat "$CONFIG_FILE" 2>/dev/null) +if printf '%s' "$CFG_CONTENT" | jq -e . >/dev/null 2>&1; then + # jq's `//` treats an explicit `false` as falsy too, so an explicit + # `"enabled": false` must be read with `has`, not `// true`. + v=$(printf '%s' "$CFG_CONTENT" | jq -r 'if has("enabled") then .enabled else true end' 2>/dev/null) && [ -n "$v" ] && ENABLED="$v" + v=$(printf '%s' "$CFG_CONTENT" | jq -r '.presence // false' 2>/dev/null) && [ -n "$v" ] && PRESENCE="$v" + v=$(printf '%s' "$CFG_CONTENT" | jq -r '.idle_seconds // 300' 2>/dev/null) && [ -n "$v" ] && IDLE_SECONDS="$v" + v=$(printf '%s' "$CFG_CONTENT" | jq -r '.stale_seconds // 1800' 2>/dev/null) && [ -n "$v" ] && STALE_SECONDS="$v" + v=$(printf '%s' "$CFG_CONTENT" | jq -r '.idle_message // "Waiting for your input"' 2>/dev/null) && [ -n "$v" ] && IDLE_MESSAGE="$v" + v=$(printf '%s' "$CFG_CONTENT" | jq -r '(.entrypoints // ["cli"]) | join(" ")' 2>/dev/null) && ENTRYPOINTS="$v" +fi +case "$IDLE_SECONDS" in ''|*[!0-9]*) IDLE_SECONDS=300 ;; esac +case "$STALE_SECONDS" in ''|*[!0-9]*) STALE_SECONDS=1800 ;; esac + +[ "$ENABLED" = "false" ] && exit 0 + +# --- headless guard ----------------------------------------------------- +if [ -n "$CLAUDE_CODE_ENTRYPOINT" ]; then + match=0 + for ep in $ENTRYPOINTS; do + [ "$ep" = "$CLAUDE_CODE_ENTRYPOINT" ] && match=1 && break + done + [ "$match" -eq 1 ] || exit 0 +fi + +STATE_DIR="${CW_NOTIFY_STATE_DIR:-${TMPDIR:-/tmp}/cw-notify}" +# Sanitise session_id before it reaches a path: keep only a safe charset, and +# treat an empty/"."/".." result as no session state at all (fail open on +# idle_prompt, skip the write on Stop/StopFailure) rather than let a hostile +# or empty id traverse out of STATE_DIR or collide across sessions. +SESSION_ID_SAFE=$(printf '%s' "$SESSION_ID" | tr -cd 'A-Za-z0-9._-') +case "$SESSION_ID_SAFE" in + ""|"."|"..") SESSION_ID_SAFE="" ;; +esac +if [ -n "$SESSION_ID_SAFE" ]; then + STATE_FILE="$STATE_DIR/${SESSION_ID_SAFE}.bg" +else + STATE_FILE="" +fi + +case "$HOOK_EVENT" in + Stop) + if [ -n "$STATE_FILE" ]; then + mkdir -p "$STATE_DIR" 2>/dev/null + { printf '%s %s\n' "$BG_COUNT" "$(date +%s)" > "$STATE_FILE"; } 2>/dev/null + fi + exit 0 + ;; + StopFailure) + if [ -n "$STATE_FILE" ]; then + mkdir -p "$STATE_DIR" 2>/dev/null + { printf '0 %s\n' "$(date +%s)" > "$STATE_FILE"; } 2>/dev/null + fi + exit 0 + ;; + Notification) + ;; + *) + exit 0 + ;; +esac + +# defensive re-check: only the seven matcher types are handled here +case "$NOTIF_TYPE" in + permission_prompt|worker_permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|push_notification|idle_prompt) ;; + *) exit 0 ;; +esac + +MSG="" +if [ -n "$AGENT_ID" ]; then + case "$NOTIF_TYPE" in + permission_prompt|worker_permission_prompt) MSG="Permission needed (agent: $AGENT_TYPE)" ;; + *) exit 0 ;; + esac +else + case "$NOTIF_TYPE" in + permission_prompt) + MSG="Permission needed" + ;; + worker_permission_prompt) + MSG="Permission needed (worker)" + ;; + elicitation_dialog|elicitation_url_dialog) + MSG="Input needed" + ;; + agent_needs_input|push_notification) + if [ -n "$MESSAGE" ]; then + MSG="$MESSAGE" + else + MSG="Attention needed" + fi + ;; + idle_prompt) + DO_PING=1 + if [ -n "$STATE_FILE" ] && [ -f "$STATE_FILE" ]; then + record="" + { IFS= read -r record < "$STATE_FILE"; } 2>/dev/null + bg_count="" + bg_epoch="" + # Require exactly " " before any arithmetic: a + # malformed record (e.g. a leading zero bash would read as octal) + # must fail open rather than error out mid-expression. + case "$record" in + *[!0-9\ ]*|"") ;; + *' '*) + bg_count="${record%% *}" + bg_epoch="${record#* }" + case "$bg_count" in ''|*[!0-9]*) bg_count="" ;; esac + case "$bg_epoch" in ''|*[!0-9]*|*' '*) bg_epoch="" ;; esac + ;; + esac + if [ -n "$bg_count" ] && [ -n "$bg_epoch" ]; then + now=$(date +%s) + age=$((now - 10#$bg_epoch)) + if [ "$((10#$bg_count))" -gt 0 ] && [ "$age" -lt "$STALE_SECONDS" ]; then + DO_PING=0 + fi + fi + fi + if [ "$DO_PING" -eq 1 ]; then + MSG="$IDLE_MESSAGE" + else + exit 0 + fi + ;; + esac +fi + +# --- title: [· - suffix>] ----------- +FOLDER=$(basename "${CWD_IN:-$PWD}") +REG_FILE="${CLAUDE_CONFIG_DIR:-$HOME_DIR/.claude}/sessions/${CLAUDE_PID}.json" +NAME="" +if [ -n "$CLAUDE_PID" ] && [ -f "$REG_FILE" ]; then + NAME=$(jq -r '.name // empty' "$REG_FILE" 2>/dev/null) +fi +if [ -z "$NAME" ]; then + TITLE="$FOLDER" +else + case "$NAME" in + "$FOLDER"-*) + rest="${NAME#"$FOLDER"-}" + TITLE="$FOLDER · $rest" + ;; + *) + TITLE="$FOLDER · $NAME" + ;; + esac +fi + +# --- sanitise title and message: control chars -> space, squeeze, truncate - +# Byte-wise and locale-independent: LC_ALL=C keeps tr from reinterpreting +# multibyte UTF-8 sequences. Only C0 controls and DEL (0x00-0x1F, 0x7F) map +# to space; every byte >= 0x80 (all UTF-8 continuation/lead bytes) passes +# through untouched. Truncation uses bash substring expansion so it is +# character-based (not byte-based) whenever the hook runs in a UTF-8 locale. +TITLE=$(printf '%s' "$TITLE" | LC_ALL=C tr '\000-\037\177' ' ' | LC_ALL=C tr -s ' ') +TITLE="${TITLE# }" +TITLE="${TITLE% }" +TITLE="${TITLE:0:100}" +MSG=$(printf '%s' "$MSG" | LC_ALL=C tr '\000-\037\177' ' ' | LC_ALL=C tr -s ' ') +MSG="${MSG# }" +MSG="${MSG% }" +MSG="${MSG:0:200}" + +# --- presence (routing only; off by default) ------------------------------- +if [ "$PRESENCE" = "true" ]; then + PSTATE="$CW_NOTIFY_PRESENCE" + if [ "$PSTATE" != "at" ] && [ "$PSTATE" != "away" ]; then + PSTATE="at" + if [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then + IDLE_NS=$(ioreg -c IOHIDSystem 2>/dev/null | grep -o '"HIDIdleTime" = [0-9]*' | head -1 | sed 's/.* = //') + case "$IDLE_NS" in ''|*[!0-9]*) IDLE_NS=0 ;; esac + IDLE_S=$((IDLE_NS / 1000000000)) + LOCKED=$(ioreg -n Root -d1 -a 2>/dev/null | plutil -extract IOConsoleUsers.0.CGSSessionScreenIsLocked raw - 2>/dev/null) + is_locked=0 + case "$LOCKED" in true|1) is_locked=1 ;; esac + if [ "$IDLE_S" -ge "$IDLE_SECONDS" ] || [ "$is_locked" -eq 1 ]; then + PSTATE="away" + fi + fi + fi + if [ "$PSTATE" = "away" ]; then + BRIDGE="" + if [ -n "$CLAUDE_PID" ] && [ -f "$REG_FILE" ]; then + BRIDGE=$(jq -r '.bridgeSessionId // empty' "$REG_FILE" 2>/dev/null) + fi + [ -n "$BRIDGE" ] && exit 0 + fi +fi + +# --- delivery --------------------------------------------------------------- +if [ "$CW_NOTIFY_DRY_RUN" = "1" ]; then + printf 'notify: %s | %s\n' "$TITLE" "$MSG" + exit 0 +fi + +if [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then + osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' -- "$TITLE" "$MSG" >/dev/null 2>&1 +elif command -v notify-send >/dev/null 2>&1; then + notify-send "$TITLE" "$MSG" >/dev/null 2>&1 +fi + +exit 0 diff --git a/tests/run.sh b/tests/run.sh index 3002dea..b7506fc 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -543,6 +543,329 @@ hooks_profile_crlf_case() { fi } +notify_case() { + # $1: 1=expect one output line containing $2, 0=expect silent + # $2: needle (ignored when $1=0), $3: description, $4: payload JSON + # $5..: EXTRA NAME=VAL overrides (applied after, and so overriding, the + # base env below; later duplicate assignments win under `env`) + local expect needle desc payload code out err lines errfile + expect="$1" + needle="$2" + desc="$3" + payload="$4" + shift 4 + errfile=$(mktemp) + out=$(printf '%s' "$payload" | env \ + CW_NOTIFY_DRY_RUN=1 \ + CW_NOTIFY_STATE_DIR="$SCRATCH/notify" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg" \ + CLAUDE_PID=4242 \ + CLAUDE_CODE_ENTRYPOINT=cli \ + HOME="$SCRATCH/base" \ + "$@" \ + "$BASH_BIN" "$HOOKS_DIR/notify.sh" 2>"$errfile") + code=$? + err=$(cat "$errfile" 2>/dev/null) + rm -f "$errfile" + if [ "$code" -ne 0 ]; then + report 0 "$desc" "exit=$code" + return + fi + if [ -n "$err" ]; then + report 0 "$desc" "stderr: $err" + return + fi + if [ "$expect" -eq 0 ]; then + if [ -z "$out" ]; then + report 1 "$desc" + else + report 0 "$desc" "expected silent, got: $out" + fi + else + lines=$(printf '%s\n' "$out" | wc -l | tr -d ' ') + if [ -n "$out" ] && [ "$lines" -eq 1 ] && printf '%s' "$out" | grep -F -q -- "$needle"; then + report 1 "$desc" + else + report 0 "$desc" "out=$out lines=$lines" + fi + fi +} + +notify_state_field_case() { + # $1: state file basename (under $SCRATCH/notify), $2: expected first + # field, $3: description + local val + val=$(awk '{print $1; exit}' "$SCRATCH/notify/$1" 2>/dev/null) + if [ "$val" = "$2" ]; then + report 1 "$3" + else + report 0 "$3" "got: $val (file: $SCRATCH/notify/$1)" + fi +} + +hooks_notify_cases() { + local p msg + + mkdir -p "$SCRATCH/cfg/sessions" "$SCRATCH/proj" "$SCRATCH/notify" + printf '%s' '{}' > "$SCRATCH/cfg/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" \ + '{name:"proj-a0", cwd:$cwd, bridgeSessionId:null}' \ + > "$SCRATCH/cfg/sessions/4242.json" + + # 1-4: silent notification types + p=$(jq -nc '{hook_event_name:"Notification",notification_type:"agent_completed",session_id:"n01"}') + notify_case 0 "" "agent_completed -> silent" "$p" + p=$(jq -nc '{hook_event_name:"Notification",notification_type:"auth_success",session_id:"n02"}') + notify_case 0 "" "auth_success -> silent" "$p" + p=$(jq -nc '{hook_event_name:"Notification",notification_type:"quota_auto_resume_fired",session_id:"n03"}') + notify_case 0 "" "quota_auto_resume_fired -> silent" "$p" + p=$(jq -nc '{hook_event_name:"Notification",notification_type:"computer_use_enter",session_id:"n04"}') + notify_case 0 "" "computer_use_enter -> silent" "$p" + + # 5: permission_prompt -> titled ping + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n05",cwd:$cwd}') + notify_case 1 "proj · a0 | Permission needed" "permission_prompt -> title + message" "$p" + + # 6: permission_prompt inside a sub-agent + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n06",cwd:$cwd,agent_id:"a1",agent_type:"reviewer"}') + notify_case 1 "agent: reviewer" "permission_prompt with agent_id -> agent-named message" "$p" + + # 7: worker_permission_prompt, no agent_id + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"worker_permission_prompt",session_id:"n07",cwd:$cwd}') + notify_case 1 "Permission needed (worker)" "worker_permission_prompt, no agent_id -> worker message" "$p" + + # 8: agent_needs_input inside a sub-agent -> silent (only the two + # permission types pass inside a sub-agent) + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"agent_needs_input",session_id:"n08",cwd:$cwd,agent_id:"a1",agent_type:"reviewer"}') + notify_case 0 "" "agent_needs_input with agent_id -> silent" "$p" + + # 9: agent_needs_input with a message + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"agent_needs_input",session_id:"n09",cwd:$cwd,message:"Need a choice"}') + notify_case 1 "Need a choice" "agent_needs_input with message -> message text" "$p" + + # 10: push_notification message carrying a quote, a backslash and an + # embedded newline -> sanitised to one output line, both halves surviving + msg=$'line one "quoted" \\ backslash\nline two' + p=$(jq -nc --arg cwd "$SCRATCH/proj" --arg msg "$msg" \ + '{hook_event_name:"Notification",notification_type:"push_notification",session_id:"n10",cwd:$cwd,message:$msg}') + notify_case 1 'line one "quoted" \ backslash line two' "push_notification message with quote/backslash/newline -> one line" "$p" + + # 11: elicitation_dialog + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"elicitation_dialog",session_id:"n11",cwd:$cwd}') + notify_case 1 "Input needed" "elicitation_dialog -> Input needed" "$p" + + # 12-13: Stop with one background task gates the paired idle_prompt + p=$(jq -nc '{hook_event_name:"Stop",session_id:"n12",background_tasks:["x"]}') + notify_case 0 "" "Stop with background_tasks -> silent" "$p" + notify_state_field_case "n12.bg" "1" "Stop background_tasks state -> first field 1" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n12",cwd:$cwd}') + notify_case 0 "" "idle_prompt, same session, bg count 1 -> silent" "$p" + + # 14-15: Stop with zero background tasks lets the paired idle_prompt ping + p=$(jq -nc '{hook_event_name:"Stop",session_id:"n14",background_tasks:[]}') + notify_case 0 "" "Stop with empty background_tasks -> silent" "$p" + notify_state_field_case "n14.bg" "0" "Stop empty background_tasks state -> first field 0" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n14",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt, same session, bg count 0 -> ping" "$p" + + # 16: idle_prompt, fresh session, no state file at all -> fail open, ping + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n16",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt, fresh session, no state file -> ping" "$p" + + # 17: Stop with the background_tasks field entirely absent + p=$(jq -nc '{hook_event_name:"Stop",session_id:"n17"}') + notify_case 0 "" "Stop with background_tasks absent -> silent" "$p" + notify_state_field_case "n17.bg" "0" "Stop absent background_tasks state -> first field 0" + + # 18: StopFailure records 0 regardless, paired idle_prompt pings + p=$(jq -nc '{hook_event_name:"StopFailure",session_id:"n18"}') + notify_case 0 "" "StopFailure, fresh session -> silent" "$p" + notify_state_field_case "n18.bg" "0" "StopFailure state -> first field 0" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n18",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt after StopFailure -> ping" "$p" + + # 19: a stale bg record (older than stale_seconds) fails open -> ping + printf '3 %s\n' "$(($(date +%s) - 7200))" > "$SCRATCH/notify/n19.bg" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n19",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt with a stale bg record -> fail open, ping" "$p" + + # 20: registry file absent -> title is the folder alone + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n20",cwd:$cwd}') + notify_case 1 "proj |" "registry absent -> title is folder alone" "$p" CLAUDE_PID=99999 + + # 21: registry name not prefixed by the folder -> " · " + mkdir -p "$SCRATCH/cfg21/sessions" + printf '%s' '{}' > "$SCRATCH/cfg21/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" \ + '{name:"mysession", cwd:$cwd, bridgeSessionId:null}' \ + > "$SCRATCH/cfg21/sessions/4242.json" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n21",cwd:$cwd}') + notify_case 1 "proj · mysession" "registry name not folder-prefixed -> folder · name" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg21" + + # 22-24: presence routing. The override is read before any OS probe, so + # these three run identically on every platform. + mkdir -p "$SCRATCH/cfg22/sessions" "$SCRATCH/cfg23/sessions" "$SCRATCH/cfg24/sessions" + printf '%s' '{"presence":true}' > "$SCRATCH/cfg22/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" '{name:"proj-a0", cwd:$cwd, bridgeSessionId:"b1"}' \ + > "$SCRATCH/cfg22/sessions/4242.json" + printf '%s' '{"presence":true}' > "$SCRATCH/cfg23/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" '{name:"proj-a0", cwd:$cwd, bridgeSessionId:null}' \ + > "$SCRATCH/cfg23/sessions/4242.json" + printf '%s' '{}' > "$SCRATCH/cfg24/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" '{name:"proj-a0", cwd:$cwd, bridgeSessionId:"b1"}' \ + > "$SCRATCH/cfg24/sessions/4242.json" + printf '0 %s\n' "$(date +%s)" > "$SCRATCH/notify/n22.bg" + printf '0 %s\n' "$(date +%s)" > "$SCRATCH/notify/n23.bg" + printf '0 %s\n' "$(date +%s)" > "$SCRATCH/notify/n24.bg" + + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n22",cwd:$cwd}') + notify_case 0 "" "presence on, away, bridge id present -> silent" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg22" CW_NOTIFY_PRESENCE=away + + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n23",cwd:$cwd}') + notify_case 1 "Waiting for your input" "presence on, away, no bridge id -> ping" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg23" CW_NOTIFY_PRESENCE=away + + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n24",cwd:$cwd}') + notify_case 1 "Waiting for your input" "presence off by default, away, bridge id present -> ping" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg24" CW_NOTIFY_PRESENCE=away + + # 25: headless guard, entrypoint not in the allowlist -> silent + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n25",cwd:$cwd}') + notify_case 0 "" "CLAUDE_CODE_ENTRYPOINT=sdk-cli -> silent" "$p" CLAUDE_CODE_ENTRYPOINT=sdk-cli + + # 26: kill switch + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n26",cwd:$cwd}') + notify_case 0 "" "CW_NOTIFY=0 -> silent" "$p" CW_NOTIFY=0 + + # 27: config file absent -> silent (opt-in gate) + mkdir -p "$SCRATCH/cfg27" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n27",cwd:$cwd}') + notify_case 0 "" "config file absent -> silent" "$p" CLAUDE_CONFIG_DIR="$SCRATCH/cfg27" + + # 28: config present but enabled:false + mkdir -p "$SCRATCH/cfg28" + printf '%s' '{"enabled":false}' > "$SCRATCH/cfg28/cw-notify.json" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n28",cwd:$cwd}') + notify_case 0 "" "config enabled:false -> silent" "$p" CLAUDE_CONFIG_DIR="$SCRATCH/cfg28" + + # 29: malformed stdin -> exit 0, silent + notify_case 0 "" "malformed stdin -> exit 0, silent" "not json" + + # 30: jq absent from PATH -> exit 0, silent + local nojq_bin u pth + nojq_bin="$SCRATCH/notify_nojq_bin" + mkdir -p "$nojq_bin" + for u in bash sh cat tr sed grep basename dirname printf readlink realpath cut head; do + pth=$(command -v "$u" 2>/dev/null) || continue + ln -sf "$pth" "$nojq_bin/$u" + done + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n30",cwd:$cwd}') + notify_case 0 "" "jq absent -> exit 0, silent" "$p" PATH="$nojq_bin" + + # 31: hooks.json shape + if jq -e '.hooks.PreToolUse|length==2' "$ROOT/hooks/hooks.json" >/dev/null 2>&1 && \ + jq -e '.hooks.Notification[0].matcher == "permission_prompt|worker_permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|push_notification|idle_prompt" and (.hooks.Stop|length)==1 and (.hooks.StopFailure|length)==1' \ + "$ROOT/hooks/hooks.json" >/dev/null 2>&1; then + report 1 "hooks.json: Notification/Stop/StopFailure bindings present" + else + report 0 "hooks.json: Notification/Stop/StopFailure bindings present" "jq -e check failed" + fi + + # 32: CW_NOTIFY_STATE_DIR pointing at a regular file -> Stop fails open + # silently, the paired idle_prompt still pings + local badfile + badfile="$SCRATCH/notify_badfile" + : > "$badfile" + p=$(jq -nc '{hook_event_name:"Stop",session_id:"n32",background_tasks:["x"]}') + notify_case 0 "" "Stop with CW_NOTIFY_STATE_DIR as a regular file -> silent" "$p" \ + CW_NOTIFY_STATE_DIR="$badfile" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n32",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt after Stop-with-bad-state-dir -> fail open, ping" "$p" \ + CW_NOTIFY_STATE_DIR="$badfile" + + # 33: background_tasks as a string (not an array) -> length-of-string bug + # must not leak through; state records 0 + p=$(jq -nc '{hook_event_name:"Stop",session_id:"n33",background_tasks:"abc"}') + notify_case 0 "" "Stop with background_tasks as a string -> silent" "$p" + notify_state_field_case "n33.bg" "0" "Stop background_tasks string -> first field 0" + + # 34: session_id path traversal -> sanitised before it reaches a path, no + # write escapes the state dir. The target dir must pre-exist so the check + # is real: without it, a pre-fix script's write would fail on a missing + # directory regardless of the sanitiser, and the case could never fail. + mkdir -p "$SCRATCH/outside" + p=$(jq -nc '{hook_event_name:"Stop",session_id:"../outside/pwned",background_tasks:["x"]}') + notify_case 0 "" "Stop with path-traversal session_id -> silent" "$p" + if [ -e "$SCRATCH/outside/pwned.bg" ]; then + report 0 "path-traversal session_id -> no state file written outside the state dir" \ + "file exists: $SCRATCH/outside/pwned.bg" + else + report 1 "path-traversal session_id -> no state file written outside the state dir" + fi + + # 35: a hand-written record with a leading zero (bash would read it as + # octal in arithmetic) must fail open rather than error out + printf '1 09\n' > "$SCRATCH/notify/n35.bg" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"idle_prompt",session_id:"n35",cwd:$cwd}') + notify_case 1 "Waiting for your input" "idle_prompt with a leading-zero bg record -> fail open, ping" "$p" + + # 36: registry name with an embedded newline -> title sanitised to one line + mkdir -p "$SCRATCH/cfg36/sessions" + printf '%s' '{}' > "$SCRATCH/cfg36/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" --arg name "$(printf 'mysession\nrogue')" \ + '{name:$name, cwd:$cwd, bridgeSessionId:null}' \ + > "$SCRATCH/cfg36/sessions/4242.json" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n36",cwd:$cwd}') + notify_case 1 "proj · mysession rogue" "registry name with embedded newline -> title sanitised to one line" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg36" + + # 37: locale-independent sanitiser -> a permission_prompt title keeps the + # middot with LANG, LC_ALL and LC_CTYPE cleared (regression: tr -c + # '[:print:]' without a locale turns the middot into a space and the + # title degrades from "proj · a0" to "proj a0") + mkdir -p "$SCRATCH/cfg37/sessions" + printf '%s' '{}' > "$SCRATCH/cfg37/cw-notify.json" + jq -n --arg cwd "$SCRATCH/proj" --arg name "a0" \ + '{name:$name, cwd:$cwd, bridgeSessionId:null}' \ + > "$SCRATCH/cfg37/sessions/4242.json" + p=$(jq -nc --arg cwd "$SCRATCH/proj" \ + '{hook_event_name:"Notification",notification_type:"permission_prompt",session_id:"n37",cwd:$cwd}') + notify_case 1 "proj · a0 | Permission needed" \ + "permission_prompt with LANG/LC_ALL/LC_CTYPE cleared -> title keeps the middot" "$p" \ + CLAUDE_CONFIG_DIR="$SCRATCH/cfg37" LANG= LC_ALL= LC_CTYPE= + + # 38: multibyte UTF-8 in the message survives the sanitiser untouched + p=$(jq -nc --arg cwd "$SCRATCH/proj" --arg msg "café — 日本 ✓" \ + '{hook_event_name:"Notification",notification_type:"push_notification",session_id:"n38",cwd:$cwd,message:$msg}') + notify_case 1 "café — 日本 ✓" "push_notification with multibyte message -> UTF-8 preserved" "$p" +} + cmd_hooks() { [ $# -eq 0 ] || usage CASE_NUM=0 @@ -569,6 +892,7 @@ cmd_hooks() { hooks_profile_cases_b hooks_profile_error_case hooks_profile_crlf_case + hooks_notify_cases printf 'hooks: %d passed, %d failed\n' "$HPASS" "$HFAIL" rm -rf "$SCRATCH" From 033c51d149f7d7b4ec3c67100bf6e6e30a41cd71 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:37:59 +0100 Subject: [PATCH 7/9] docs: 1.0.1 notifier, turn caps, lean loop and stall ladder in README, WORKFLOW and the draft Co-Authored-By: Claude --- README.md | 70 ++++++++++++++++--- WORKFLOW.md | 26 ++++--- .../plans/cw-plugin/cw-plugin-design-draft.md | 31 +++++++- 3 files changed, 106 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7aca6ae..08a75ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cw — a Claude Code workflow plugin -This repo *is* the Claude Code plugin `cw`: six skills, five agents, three +This repo *is* the Claude Code plugin `cw`: six skills, five agents, four hooks, three rule files, one test runner, and CI. It implements a `/cw:design` -> `/cw:build` -> `/cw:ship` pipeline — research-grounded planning, delegated implementation with one atomic commit per task, and a @@ -79,15 +79,27 @@ Loads the plugin for that session only. `disable-model-invocation: true` deliberately — they change files, branches, or open PRs, so they only run when you type the command. +### The design review loop + +`/cw:design` converges with two `cw:design-reviewer` rounds by default (a +third only after a verified critical finding survives round two), then runs +`cw:direction-reviewer` once, but only when the design is architectural (a new +subsystem, a public interface or schema, or a change to the loop itself). +Append `lite` — `/cw:design lite` — for a fix-up touching three +files or fewer: one research angle, one review round, no direction pass. A +review round that comes back with no parseable verdict follows the stall ladder +in `rules/orchestration.md` (nudge, nudge, respawn, escalate) rather than a +blind retry. + ## Agents -| Agent | Model | Role | -|---|---|---| -| `cw:researcher` | sonnet | Web research, read-only | -| `cw:implementer` | sonnet (opus per-task for hard work) | Writes code, makes the task's commit | -| `cw:reviewer` | opus | Reviews an implementer's diff, read-only | -| `cw:design-reviewer` | opus | Doc/codebase fidelity review of a design draft | -| `cw:direction-reviewer` | opus, effort xhigh | Premise/direction review, once per design | +| Agent | Model | Turn cap | Role | +|---|---|---|---| +| `cw:researcher` | sonnet | 30 | Web research, read-only | +| `cw:implementer` | sonnet (opus per-task for hard work) | 50 | Writes code, makes the task's commit | +| `cw:reviewer` | opus | 40 | Reviews an implementer's diff, read-only | +| `cw:design-reviewer` | opus | 40 | Doc/codebase fidelity review of a design draft | +| `cw:direction-reviewer` | opus, effort xhigh | 15 | Premise/direction review, once per architectural design | `/cw:design` also spawns the built-in `Explore` agent with `model: haiku` on each call; the plugin ships no agent by that name — its agents load under the @@ -186,7 +198,7 @@ claude plugin disable cw@skills-dir # install path (a) claude plugin disable cw@claude-code-setup # install path (b) ``` -Turns all three hooks off at once. This is also the answer for a repo that +Turns all four hooks off at once. This is also the answer for a repo that pushes to `main` by convention — `protect-branches.sh` has no opt-out environment variable by design, so disabling the plugin is the supported escape hatch, not a bypass flag on the hook itself. @@ -216,6 +228,46 @@ and a line of prose that spells out a push to the default branch inside a command is blocked as if it were the command itself — keep such text in a file instead. +## Notifications + +Opt-in: create `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cw-notify.json` — an empty +`{}` turns it on with every default. `notify.sh` binds to `Notification`, +`Stop` and `StopFailure`, pinging only when a human is actually waited on. + +Immediate pings (`Notification`): `permission_prompt` ("Permission needed"), +`worker_permission_prompt` ("Permission needed (worker)", agent teams), +`elicitation_dialog`/`elicitation_url_dialog` ("Input needed"), and +`agent_needs_input`/`push_notification` (the event's message, or "Attention +needed"). Inside a sub-agent only the two permission types pass, naming the +agent type. Silent by design: `agent_completed`, `auth_success`, +`computer_use_enter`/`computer_use_exit`, and the three quota types — a +completed agent isn't a human wait, and quota events are noise. + +The waiting ping is the built-in `idle_prompt`, about 60 seconds after a turn +ends, gated on the background-task count every `Stop` records for the session +(`StopFailure` records 0): it pings only when that count is 0, and a missing +or stale record (older than `stale_seconds`) also counts as 0, so the hook +fails open. + +Config keys in `cw-notify.json`, all optional: `enabled` (true), `presence` +(false), `idle_seconds` (300), `stale_seconds` (1800), `entrypoints` +(`["cli"]`), `idle_message` ("Waiting for your input"). `presence`, off by +default, only routes delivery: idle past `idle_seconds`, or a locked screen +with a Remote Control bridge, skips the banner (the phone channel already +delivers); a probe failure counts as "at the machine". `CW_NOTIFY=0` is the +kill switch; `CW_NOTIFY_DRY_RUN=1` prints the banner line instead of showing +it. Title is ` · ` from the session registry, or the folder +alone when the registry is absent. + +Linux delivers through `notify-send` when present, otherwise nothing. macOS +15+ delivers the banner as **Script Editor** and needs that app allowed under +System Settings › Notifications, or the hook exits 0 silently +(practitioner-sourced — Apple doesn't document this). + +If a profile still has a user-level `Notification` hook in `settings.json` +from before this plugin, remove it by hand: `notify.sh` replaces it, and a +plugin hook and a settings hook bound to the same event both fire. + ## Migration from the old installer If you previously installed this project's files directly into `~/.claude` diff --git a/WORKFLOW.md b/WORKFLOW.md index 8a57c62..d69eb04 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -22,8 +22,14 @@ closes the loop by feeding lessons back into project rules. Plan mode. Reads project context, spawns `Explore` (haiku) and `cw:researcher` (sonnet) subagents in parallel to ground the plan in the real codebase and current sources, drafts one plan file with a task list, then -loops `cw:design-reviewer` (fresh context each pass, until findings converge, -escalating after 5 iterations) followed by `cw:direction-reviewer` once. +loops `cw:design-reviewer` — fresh context each pass, two rounds by default, a +third only after a verified critical finding survives round two — followed by +`cw:direction-reviewer` once, but only when the design is architectural (a new +subsystem, a public interface or schema, or a change to the loop itself). +`/cw:design lite` trims this to one research angle, one review +round and no direction pass, for a fix-up touching three files or fewer. A +review round with no parseable verdict follows the stall ladder in +`rules/orchestration.md` rather than a blind retry. The skill calls `ExitPlanMode` itself when the review loop converges — that approval only ends drafting; it is not the real checkpoint. Plan mode writes @@ -54,8 +60,8 @@ itself a permission mode). The orchestrating session: 5. Spawns `cw:reviewer` — a different model tier than the implementer used — to review the diff fresh from disk. `NEEDS_WORK` respawns a new implementer with the feedback (up to 2 retries); a reviewer that returns - no parseable verdict at all is retried once fresh, not treated as a pass - or a fail. + no parseable verdict is neither a pass nor a fail — it follows the stall + ladder in `rules/orchestration.md` and never consumes a review iteration. 6. Stops for your approval once every task's commit is in and the tasks-file quality gate passes. @@ -99,13 +105,14 @@ driving Google Docs/Drive from Claude, triggered when a task needs one. format, checkpoints, mode transitions) and is meant to be imported by a profile's `CLAUDE.md`. `rules/orchestration.md` carries the model-routing table (public aliases, route-up-for-hard-work, the workflow-fan-out -threshold) and the review-loop semantics (`NO_VERDICT` vs `NEEDS_WORK`, -counter-model review, ground-truth verification). If a rule and this file -ever disagree, the rules file wins. +threshold) and the review-loop semantics (the stall ladder for a missing or +unparseable verdict, distinct from `NEEDS_WORK`, counter-model review, +ground-truth verification). If a rule and this file ever disagree, the rules +file wins. ## Hooks -Three hooks, registered by the plugin itself (`hooks/hooks.json`), fire in +Four hooks, registered by the plugin itself (`hooks/hooks.json`), fire in every session the plugin is enabled in: | Hook | Event | What it does | @@ -113,10 +120,11 @@ every session the plugin is enabled in: | `protect-branches.sh` | PreToolUse (Bash) | Blocks a push whose destination is main/master, and any force push | | `protect-secrets.sh` | PreToolUse (Write\|Edit\|MultiEdit\|NotebookEdit) | Blocks writes to `.env`, credentials, keys, and similar secret paths | | `profile-check.sh` | SessionStart | Warns if the active profile doesn't match `~/.claude-profiles`, or if a `CLAUDE.md` `@import` target is missing | +| `notify.sh` | Notification, Stop, StopFailure | Opt-in desktop notifier, gated on `cw-notify.json` — see README's Notifications section | A hook that exits non-zero is enforced, not advisory — CLAUDE.md conventions get followed inconsistently; a `PreToolUse` hook that exits 2 blocks the call -outright. `claude plugin disable cw@` turns all three off at once. +outright. `claude plugin disable cw@` turns all four off at once. ## Tests diff --git a/docs/plans/cw-plugin/cw-plugin-design-draft.md b/docs/plans/cw-plugin/cw-plugin-design-draft.md index 2e91a3d..119be9a 100644 --- a/docs/plans/cw-plugin/cw-plugin-design-draft.md +++ b/docs/plans/cw-plugin/cw-plugin-design-draft.md @@ -34,7 +34,7 @@ row 6); profile-check never exits 2 (§R2.5); `disallowed-tools` on `/cw:build` delegate guard (§R2.6, §R3 row 7); marketplace name distinct from the plugin name (§R3 row 4); `claude plugin validate` in CI (§R3 row 5). Rev 2 (2026-09-16) after review round 1 (direction PASS with advisories; design-reviewer -NO_VERDICT once, re-nudged; completeness NEEDS_WORK). Verified and folded: the scrub check +returned no parseable verdict once, re-nudged; completeness NEEDS_WORK). Verified and folded: the scrub check would have matched the committed artifacts (the name is now derived from `LICENSE` at check time); P0 lacked the artifact write and forbade it with a clean-tree check (Step 6 of `/design` writes the set; the check tolerates it); `disallowed-tools` is scoped to the invoking turn @@ -163,7 +163,7 @@ inherits lives in `rules/`, and the hooks enforce the hard ones regardless of im | one atomic commit per task | build | | co-author trailer (`Co-Authored-By`) | `rules/workflow.md` (build refers to "the trailer the workflow rules define") | | PR only after the gate | ship | -| NO_VERDICT handling | `rules/orchestration.md` | +| unparseable-verdict handling (the stall ladder) | `rules/orchestration.md` | | ground truth over testimony | `rules/orchestration.md` | | fresh-context counter-model reviewer | `rules/orchestration.md` | | same-turn spawn | `rules/orchestration.md` | @@ -278,7 +278,7 @@ promotion, no mirror. Its Explore calls keep `model: haiku`. `/cw:build [ `docs/plans//-tasks.md` with `-design-draft.md` beside it (the layout this build uses), commits the design file(s) as `docs(): design` through git when untracked or modified (no `.gitattributes` write), hands each implementer only its task block, keeps the -ground-truth gate, counter-model review, NO_VERDICT retry and three iterations, and states +ground-truth gate, counter-model review, unparseable-verdict retry and three iterations, and states the delegation boundary in one sentence (the orchestrator edits no file; every change is an implementer's) backed by the review gate — no guard hook and no tool restriction: the P0 probe showed that `disallowed-tools` on a skill also strips the tools from the subagents it @@ -553,3 +553,28 @@ T6 README (with the changelog), WORKFLOW, project CLAUDE.md, example settings, s commits) → `/ship` (gate from the project CLAUDE.md; the `*secret*` match waved through) → CI → merge on GitHub → post-merge marketplace install check → `git switch main && git pull` → plan A. Cut line per D2.10. + +## Release 1.0.1 + +Why: measured stalls and notification noise from 1.0.0's first week. Deep reviewers on the +old 20-turn cap ended without a parseable verdict in 4 of 4 runs on 2026-09-21 and 2 of 4 on +2026-09-22; every one recovered with a single nudge, never a respawn — so the fix is more +turns plus a documented ladder, not a different model. One design session measured about +1.6M sub-agent tokens plus a full orchestrator window, motivating the `lite` path and the +two-round-by-default cap. The prior user-level `Notification` hook produced 110 banners in +one day, 62% of them `idle_prompt` firing while background agents still ran, motivating the +notifier's background-task gate. + +What changed: +- Turn caps: `cw:reviewer` and `cw:design-reviewer` 40 (up from 20), `cw:direction-reviewer` + 15, `cw:implementer` 50, `cw:researcher` 30. The three reviewer agents drop `memory` from + their frontmatter — findings live in the report file, not agent memory. +- `/cw:design` review loop: two `cw:design-reviewer` rounds by default (a third only after a + verified critical finding survives round two), a `lite` flag for fix-ups (one research + angle, one review round, no direction pass), and the direction pass now runs only when + Step 1 records `architectural: yes`. A missing or unparseable verdict follows the stall + ladder in `rules/orchestration.md` (nudge, nudge, respawn, escalate) instead of a blind + retry. +- New opt-in `notify.sh` hook bound to `Notification`, `Stop` and `StopFailure`, gated on + `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cw-notify.json`; see README's Notifications section + for the field list and delivery paths (macOS Script Editor, Linux `notify-send`). From 70c1b5ea7bcdc6ecaebd8ede65f218b60b4b9e31 Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Tue, 22 Sep 2026 20:55:14 +0100 Subject: [PATCH 8/9] chore(release): version 1.0.1 and the marketplace description Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 1 + .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cd81f34..61be250 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,6 @@ { "name": "claude-code-setup", + "description": "Marketplace for the cw plugin, a design/build/ship workflow of skills, orchestration agents, and guardrail hooks for Claude Code.", "owner": { "name": "Victor Kzam" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 609980d..c5b5e61 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cw", - "version": "1.0.0", + "version": "1.0.1", "description": "Claude Code workflow plugin: design/build/ship skills, orchestration agents, and guardrail hooks.", "author": { "name": "Victor Kzam" From e4b797c1a8298626bb9519519cb5f9afdad787af Mon Sep 17 00:00:00 2001 From: Victor Kzam Date: Wed, 23 Sep 2026 00:33:03 +0100 Subject: [PATCH 9/9] test(hooks): widen the guard speed budget to two ticks and retry a timing miss once The four speed cases judged the branch guard with bash's integer SECONDS counter against a budget of 1, so a healthy run of about 0.65-0.75 s (measured locally under /bin/bash 3.2 on the 5000-segment and 12000-token payloads) had no headroom on a slower runner: the macOS job read 2 ticks with the right exit code on case 85 and failed the pull_request run on 2026-09-22, while the push run on the same commit passed. The cases exist to catch pathological backtracking, not to hold a latency target, so the budget is now 2 ticks (under 3 s of wall clock) and a timing-only miss with the expected exit code gets one retry. A wrong exit code still fails on the first attempt. Co-Authored-By: Claude --- tests/run.sh | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index b7506fc..26462e5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -78,21 +78,35 @@ push_case_repo() { speed_case() { # $1: expected code, $2: description, $3: cwd, $4: command - local expected desc cwd cmd payload code secs + # The budget guards against pathological backtracking in the guard, not a + # latency target. SECONDS counts whole-second ticks, so a reading of 2 means + # the hook finished in under 3 s of wall clock. Calibration under /bin/bash + # 3.2 on an M-series Mac (2026-09-23): the 5000-segment and 12000-token + # payloads take about 0.65-0.75 s, and a shared macOS runner read 2 ticks on + # a healthy hook (2026-09-22), so a timing-only miss with the expected exit + # code gets one retry; a wrong exit code fails at once. + local expected desc cwd cmd payload code secs attempt expected="$1" desc="$2" cwd="$3" cmd="$4" payload=$(jq -nc --arg c "$cmd" --arg d "$cwd" '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}') - SECONDS=0 - printf '%s' "$payload" | HOME="$SCRATCH/base" "$BASH_BIN" "$HOOKS_DIR/protect-branches.sh" >/dev/null 2>&1 - code=$? - secs=$SECONDS - if [ "$code" -eq "$expected" ] && [ "$secs" -le 1 ]; then - report 1 "$desc" - else - report 0 "$desc" "exit=$code want=$expected secs=$secs" - fi + attempt=1 + while :; do + SECONDS=0 + printf '%s' "$payload" | HOME="$SCRATCH/base" "$BASH_BIN" "$HOOKS_DIR/protect-branches.sh" >/dev/null 2>&1 + code=$? + secs=$SECONDS + if [ "$code" -eq "$expected" ] && [ "$secs" -le 2 ]; then + report 1 "$desc" + return + fi + if [ "$code" -ne "$expected" ] || [ "$attempt" -ge 2 ]; then + report 0 "$desc" "exit=$code want=$expected secs=$secs attempt=$attempt" + return + fi + attempt=$((attempt + 1)) + done } REPOS_OK=0 @@ -414,7 +428,7 @@ hooks_resolution_cases() { hooks_speed_cases() { local big seg2 i seg3 letters seg4 big=$(printf '%*s' 100000 '' | tr ' ' 'a') - speed_case 2 "100k-char token + push main -> blocked, under 1s" "/tmp" "$big && git push origin main" + speed_case 2 "100k-char token + push main -> blocked, under 3s" "/tmp" "$big && git push origin main" seg2="" i=1 @@ -426,7 +440,7 @@ hooks_speed_cases() { fi i=$((i + 1)) done - speed_case 2 "5000 && segments + push main -> blocked, under 1s" "/tmp" "$seg2 && git push origin main" + speed_case 2 "5000 && segments + push main -> blocked, under 3s" "/tmp" "$seg2 && git push origin main" letters="abcdefghijklmnopqrstuvwxyz0123456789ABCD" seg3="" @@ -440,7 +454,7 @@ echo line-$i-$letters" fi i=$((i + 1)) done - speed_case 0 "2000-line heredoc-shaped input, no push -> allowed, under 1s" "/tmp" "$seg3" + speed_case 0 "2000-line heredoc-shaped input, no push -> allowed, under 3s" "/tmp" "$seg3" seg4="" i=1 @@ -452,7 +466,7 @@ echo line-$i-$letters" fi i=$((i + 1)) done - speed_case 2 "one ~100k-char segment (many short cd-containing tokens) + push main -> blocked, under 1s" "/tmp" "$seg4 && git push origin main" + speed_case 2 "one ~100k-char segment (many short cd-containing tokens) + push main -> blocked, under 3s" "/tmp" "$seg4 && git push origin main" } hooks_input_cases() {