Conversation
The cycle goes brainstorm -> ??? -> plan, and the only thing bridging the gap is a source comment in brainstorm.js telling the operator to write the approved design to docs/spec/ — a directory that emptied in 0.11.9. So the one artifact carrying a design from divergence into planning is prose the operator retypes: no file, no provenance, no source stamp, no ledger row, and nothing surviving a compaction. The evidence gate's own premise, not applied to the stage that needs it most. docs/CYCLE.md specifies the three pieces that close it, design only: - the spec artifact — .operator/specs/<slug>.md, tracked, fixed section order, a north star carrying `Missed if:` that BOTH the BAR block and plan.js's args.northStar read (today there are two north stars, written by hand, with nothing linking them); - the approval stamp — ops-spec.sh --approve reuses ops-verdict.sh's source_stamp ladder, logs a SPEC-APPROVED record kind, and emits the BAR block the charter requires by hand today; - the derived stage — ops-stage.sh computes where the engagement is from artifacts on disk, report-only, sharing lib/partition.sh. Derived, never stored: UNKNOWNS.md's rule about status in two places. It prices what it proposes rather than leaving it to the fourth review round: a tracked .operator/specs/ needs the gitignore allowlist bumped to v3 in both writers, and the migration's backup path is hardcoded two-state, so a v3 migration writes a .v1.bak that lies about its contents. Also repairs three dangling docs/spec/ references (README, CONTRIBUTING, docs/HANDOUT) — the F61 class, regressed since it was last fixed. Gates: validate_plugin green; 397 python, 1085 bash (16 skipped, uid 0), 384 + 161 node — all passing, unchanged from baseline (docs-only diff). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
…CLE §5) Measured: `grep -rn IMPLEMENT workflows/` returns nothing across all six. IMPLEMENT is one of the four canonical tiers (ops-tiers.sh TIER_NAMES, default claude-sonnet-5, documented in README and commands/tiers.md), and ops-render.sh binds `seat_add mechanic IMPLEMENT default` — so the one seat on the tier is the one seat no workflow can reach on it. Two routes exist and both are wrong for an engagement in flight: - a plain Agent call reads the agent file's frontmatter alias, resolved at session start, and the harness's Agent tool locks `model` to the enum sonnet|opus|haiku|fable — re-measured against this session's own tool schema on 2026-09-20, the re-check commands/tiers.md asks for. A cc-proxy id is refused before dispatch (#55, still true). The render route applies the binding globally and needs a session restart, mid-engagement, which is the event the RECOVERY PROTOCOL exists to survive. - dispatch.js resolves `model || JUDGMENT` with only JUDGMENT in its DEFAULT_TIERS, so a mechanic dispatched without an explicit args.model — the IMPLEMENT-tier seat — runs on the judgment default. The log line says which happened, which makes it honest, not correct: a silent tier PROMOTION, and #153 already measures what a mis-bound tier costs. The asymmetry names the defect: every stage of the cycle that only READS runs as a workflow with its own tier map; the one stage that WRITES CODE is a plain Agent call against a hardcoded alias. §5 specifies workflows/implement.js — the first workflow to dispatch IMPLEMENT, taking the dispatch packet as structured args and refusing an incomplete one before spending a seat (#152 landing where it belongs), serializing implementers so the charter's one-at-a-time rule becomes a mechanism rather than prose, and stopping at the REPORT because the sandbox has no filesystem and cannot touch the ledger. §5.4 carries the smaller independently-shippable fix to dispatch.js's fallback, with the constraint that the seat->tier binding must not gain a second declaration. Gates: validate_plugin green; docs-only diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
…YCLE §5.4) Decision on the open question in §5.4. The ladder is args.model, then args.tier resolved against the caller-supplied map (with IMPLEMENT and MECHANICAL added to DEFAULT_TIERS so a bare invocation resolves), then — when neither is named — NO `model` key in the agent() options at all. The current `model || JUDGMENT` invents a choice: it promotes an IMPLEMENT-tier seat to the judgment default and calls that a defensible fallback. Omitting the key declines to choose, which is the only honest move available to a workflow that cannot read tiers.env, and it leaves the decision with the layers that already own it — the agent file's frontmatter, a project-layer agent from ops-render.sh, $CLAUDE_CODE_SUBAGENT_MODEL, or a CLAUDE.md instruction. Renderer and dispatcher stop competing: render sets the standing default, dispatch overrides per call, and an absent override no longer overwrites that default with a third answer. Recorded as an assumption, not a measurement: what this repo has measured is the converse — opts.model OVERRIDES the agent file's frontmatter (2026-07-29, cited in review.js). That an OMITTED opts.model leaves the frontmatter in effect is the expected complement and owes a live dispatch against a seat with a distinguishable frontmatter model before it ships. If it comes back otherwise, the rung becomes an explicit refusal. Unchanged: the seat->tier binding gains no second declaration. It stays in ops-render.sh's seat_add lines, and args.tier is named by the caller so the workflow never holds that map. Gates: validate_plugin green; docs-only diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
…158) The fallback was `model || JUDGMENT` with only JUDGMENT in DEFAULT_TIERS, so a seat dispatched without an explicit args.model ran on the judgment default — including `mechanic`, which ops-render.sh binds to IMPLEMENT (seat_add mechanic IMPLEMENT default). The log said which happened, which made it honest, not correct: a silent tier PROMOTION, the cost class #153 measures, reached by a different door. The ladder now: 1. args.model — an explicit id, same charset guard a tiers.env binding gets. 2. args.tier — a tier NAME (case-insensitive) resolved against the caller's args.tiers map. DEFAULT_TIERS gains IMPLEMENT/MECHANICAL/RECON so a bare invocation still resolves, to that tier's own harness alias and never to JUDGMENT's. An unknown name is REFUSED, never defaulted. 3. neither — no `model` key in the agent() options at all. The seat runs on its own configured default: agents/op-<seat>.md frontmatter, a project-layer agent from `/cc-operator:tiers render`, or $CLAUDE_CODE_SUBAGENT_MODEL. Renderer and dispatcher stop competing — render sets the standing default, dispatch overrides per call, and an absent override no longer overwrites that default with a third answer. TIER VALUES ARE NOW VALIDATED LAZILY, and that is required rather than incidental. Eager validation of every TIERS key was sound while JUDGMENT was the only dispatchable tier: every key was reachable on every call. With four tiers nameable and at most one reached per call, an eager loop resurrects what PR #78 removed one level down — a malformed value on a tier THIS CALL never touches failing the run, which makes forwarding the resolver's whole map unsafe the moment any single tiers.env binding is malformed. The guard is unchanged in strength; only its subject narrowed to the id that reaches agent(). OWED MEASUREMENT, recorded in the code and in #158: what this repo has measured is the converse — opts.model OVERRIDES the agent file's frontmatter (2026-07-29, cited in review.js). That an OMITTED opts.model leaves the frontmatter in effect is the expected complement, not something measured here. The returned modelSource field is what a live run reads it off; a dispatch that comes back "seat-default" and ran on something else refutes rung 3, which then becomes an explicit refusal. Three mutations, each red in tests/test_workflows.mjs before the fix: - restoring `model || TIERS.JUDGMENT` -> 2 red (the absent-key assertion and the seat-default return), with a CONTROL proving the absence check can fail; - tolerating an unknown args.tier -> 1 red (it throws, but with a misleading "is not a model id string", so the message is pinned too); - re-adding eager validation -> 1 red (the F07 unused-key property). Prose that documented the old fallback is corrected in the same commit: commands/tiers.md's dispatch paragraph and the README workflow table. Gates: validator all contracts hold · workflows 395@395 (floor 384 -> 395, same commit) · compress 161 · python 397 · shell 1085 + 16 skipped. shellcheck is not installed in this container; no shell file changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
`grep -rn IMPLEMENT workflows/` returned nothing across all six. IMPLEMENT is one of the four canonical tiers (ops-tiers.sh TIER_NAMES) and the tier ops-render.sh binds the implementer to (seat_add mechanic IMPLEMENT default), and no workflow dispatched it — so every stage of the cycle that only READS ran as a workflow with a tier map while the one stage that WRITES CODE was a plain Agent call against a hardcoded frontmatter alias. workflows/implement.js, per docs/CYCLE.md §5.3: - FIRST workflow to dispatch IMPLEMENT. mechanic defaults to IMPLEMENT and author to JUDGMENT — what ops-render.sh's seat_add lines say — and args.tier overrides, so this is a default, not a second declaration of the seat->tier map. Same ladder as dispatch.js: args.model, args.tier, then no model key. - TAKES THE DISPATCH PACKET as structured args and refuses an incomplete one with ZERO agents dispatched, naming EVERY defect at once rather than one per round (#152's point one level in; #84 measured what the other order costs — 7 agents, 123,935 tokens, every seat answering it could not proceed). - SERIAL. The charter's one-implementer-at-a-time rule [D:CHART-r6] stops being prose the operator must obey and becomes a property of the script. - Hands the seat the four-status protocol as a SCHEMA ENUM and `changed` as an ARRAY — the operator routes on status, and a prose CHANGED line is what a diff cannot be checked against. - STOPS on a dead seat rather than stepping over it: the next task may depend on this one's output. Completed work is kept and `dispatched/requested` says a short run was short. - Returns the unioned CHANGED and stops there. The sandbox has no filesystem, so ops-claims.sh, the sentinel and the verdict row stay with the operator — and the return says out loud that CHANGED is the seats' CLAIM. check_implement_packet is the new pin. PACKET_FIELDS is the FOURTH hand-copy of the charter's dispatch packet and the first in code, so it is pinned rather than trusted (F30): every field present, no field the charter lacks, the four statuses complete, the charter still teaching both — and the APPLICATION, because a field required by the refusal and then dropped before the prompt is worse than one never required (the refusal implies it was used). An absent implement.js is a FINDING, not a skip (#114). Ten python cases, each its own mutation, all red in check_implement_packet: dropped field, extra field, validated-then-unsent, the application moved into a comment (F48/F57's shape), a dropped status, the charter losing a field, the charter losing a status, a missing file, a renamed locator — plus the independent-copy equality that fails loudly when the constant changes. Serialization is pinned by a source scan (no `parallel(` in the file) WITH a brainstorm.js CONTROL: the suite's stub runs parallel() thunks sequentially, so a concurrency counter would pass for a parallel implementation — vacuous. CLAUDE.md sits at 37985/38000 chars after this row. The narrative went to docs/LANDMINES.md per the cap's own instruction; the next coupling will not fit without another extraction. Gates: validator all contracts hold · workflows 430@430 (floor 395 -> 430) · python 407@407 (floor 397 -> 407) · compress 161@161 · shell 1101@1101. shellcheck is not installed in this container; no shell file changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
Six workflows shipped with no command surface: the operator hand-built
`Workflow({name, args})` calls and hand-pasted a model id resolved by a
separate script. That hand-paste IS #55 arriving at the call site — skip it
and every seat runs on a harness alias while tiers.env says otherwise, with
nothing warning. commands/tiers.md documents the two-step under its own
warning; this removes the step rather than documenting it again.
commands/{brainstorm,plan,implement,review,crawl,debate}.md. Each resolves
the bindings with `ops-tiers.sh --json` — the machine-readable form whose own
comment says it exists for Workflow({args:{tiers:...}}) — and passes the one
line through verbatim. dispatch.js deliberately gets no command: it is the
low-level escape hatch /cc-operator:tiers documents and takes a resolved id
by construction.
The bodies carry what the workflow cannot enforce from inside:
- brainstorm: pass the topic IN FULL (#92's 123,935-token fan-out against a
placeholder is reproduced honestly by summarizing the brief), then
interview one question at a time in the order the bundle already sorted.
- plan: the north star must be the SAME sentence as the BAR block's, because
nothing keeps the two in step and a plan vetted against a goal the ledger
never agreed to fails silently (docs/CYCLE.md §2).
- implement: every packet field, and then close the loop yourself —
ops-claims.sh and ops-verdict.sh, because the workflow has no filesystem.
- review: doneMeans is not optional in practice; the spec and testability
lenses ask about the task and without it review nothing.
- crawl: a dead crawler is lost coverage, not an empty shard.
- debate: use it only when the question cannot be settled by running
something, and pick ids that DIFFER.
Pinned in the bash suite per command, not trusted to the prose: the --json
resolution, the grant covering the prescription (#104's rule — both the Bash
form and the Workflow tool in allowed-tools), and that each dispatches ITS
OWN workflow (check_commands reads frontmatter and paths and knows nothing
about which workflow a body invokes). Coverage, not equality: every workflow
except dispatch must have a command, so deleting one fires, while adding
commands/dispatch.md later is legal and automatically checked. The loop's own
vacuity is checked (>=5 commanded workflows) and start.md is the CONTROL that
the greps can come back negative.
Three mutations, each red in this case:
- review.md resolving --show instead of --json -> 1 red;
- debate.md losing the Workflow grant -> 1 red;
- crawl.md deleted -> 1 red (the coverage half).
No CLAUDE.md coupling row: the file sits 15 chars under its cap and the
extraction that would make room is #159.
Gates: validator all contracts hold · shell 1133@1133 (floor 1101 -> 1133,
same commit) · workflows 430 · python 407 · compress 161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
Seven stages, and until #75 exactly one command. Every transition between them was the operator remembering to make it, and after a compaction the RECOVERY PROTOCOL's seven steps are the only path back — prose the operator must CHOOSE to follow. This puts the answer in the one channel a session reads before doing anything: the SessionStart banner. scripts/lib/stage.sh is a PURE function. It opens no file: stage_derive takes the counts scan_pending already computed plus scan_deviations' verdict, so it adds no reader, no byte cap, no NUL probe and no second copy of the partition rule. That is what lets the Stop hook (which runs every scan) and the SessionStart hook (which runs only the pending one) share one derivation without disagreeing — they differ in what they KNOW, and the unknown input has its own answer rather than a guessed one: SessionStart passes "-" for the deviation gate and the stage then never claims HANDOFF. Rungs, in precedence order, each one making the next unreachable: BLOCKED (a sentinel whose name no CLI can close) > IMPLEMENT (a task of mine open) > HANDOFF (unpresented decisions) > CLEAR. A foreign task is REPORTED and is never a stage — another session's open work changes nothing about what this session should do next. DERIVED, NEVER STORED, per docs/UNKNOWNS.md's rule about status in two places: there is no engagement.json and there must not be one. REPORT-ONLY, like the cap detector: nothing here exits, writes, or changes a gate's verdict, and a case refuses an `exit` in any stage_derive branch. The DIVERGE/SPEC/PLAN rows of docs/CYCLE.md §6 wait on #155 — with no spec artifact there is nothing on disk to derive them from, and inventing a proxy would be the guess this design refuses. §6 now says so. Wiring is FAIL-SILENT and that polarity is load-bearing: the id injection is the root of the whole ownership mechanism, so a missing lib costs the stage line and nothing else. Its guard is `-f`, not `-r` — the validator refuses a permission test here because uid 0 bypasses mode bits, so `-r` would read as a guard while guarding nothing (caught by check_permission_guards on the first run of this change, which is the gate working). Three mutations, each red in the bash suite: - disabling the HANDOFF rung -> 1 red; - inverting IMPLEMENT/HANDOFF precedence -> 1 red; - removing the banner's stage line -> 3 red (the end-to-end half, which is what catches a `&& \` continuation followed by a comment — a shape bash -n accepts and nobody can read; it was in the first cut of this wiring). Plus a CONTROL that the banner reports CLEAR when nothing is open, and one that a scanned-clean gate carries no "not scanned" caveat. Gates: validator all contracts hold · shell 1151@1151 (floor 1133 -> 1151, same commit) · workflows 430 · compress 161 · python 407 unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
dispatch.js and implement.js omit the `model` key entirely when the caller names neither a model nor a tier, so the seat runs on its own configured default. That rested on a complement this repo had never measured: what was recorded is the CONVERSE — opts.model OVERRIDES the agent file's frontmatter (2026-07-29, cited in review.js) — and it was carried in the code as an admitted assumption. MEASURED 2026-09-21. Two seats, same agentType (a project-layer agent pinning `model: opus`), one dispatch with no `model` key and one with `model: "haiku"`: seat "omitted" per-agent metadata: no `model` key served claude-opus-5 seat "explicit" per-agent metadata: "model":"haiku" served claude-haiku-4-5-20251001 Omitting the key hands the decision to the agent definition, which is what the rung claims. Not covered, and said so where it is written down: a cc-proxy id rather than a harness alias, and a plugin-root agent rather than a project-layer one — neither is the mechanism under test. The note moves from "owed" to the measurement in all three places that carried it: the comment at the call site in dispatch.js, docs/CYCLE.md §5.4, and docs/LANDMINES.md. Writing it down as an assumption is what made it cheap to close; phrasing it as a fact is the class LANDMINES exists for. Gates: validator all contracts hold · workflows 430 · no behavior changed (comments and prose only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
) validate went red on f6d9b6f and 8631e8d: In scripts/lib/stage.sh line 64: STAGE="CLEAR" ^---^ SC2034 (warning): STAGE appears unused. Correct, and the same shape partition.sh, caps.sh and autobar.sh each carry a file-scoped directive for: a lib's globals are consumed by the SOURCING script, which shellcheck cannot see. stage.sh shipped without the line the three siblings have had since they were written. This is the cost of "shellcheck is not installed in this container", repeated across three commits. It is installed now (pip install shellcheck-py, 0.11.0) and the failure was REPRODUCED before the fix and the same command shown clean after: before: scripts/lib/stage.sh line 64 SC2034, rc=1 after: rc=0 One version caveat, stated rather than papered over: CI pins the koalaman/shellcheck-alpine:v0.10.0 image and this container has no docker daemon, so the local binary is 0.11.0. The difference that matters is SC2329 (added in 0.11), which fires on ops-verdict.sh's fallback_release — a PRE-EXISTING info that 0.10 does not emit and CI therefore never saw. It is not this PR's and is not touched here; `--exclude=SC2329` reproduces 0.10's finding set exactly, and that run is clean. Gates: validator all contracts hold · shell 1135 + 16 skipped · workflows 430 · shellcheck clean at 0.10's finding set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
THE CHARTER (#159 part 1). ORCHESTRATED MODE said "The review, brainstorm, plan and debate workflows are the primitives" while workflows/implement.js shipped in #158 — so the operator running under the charter was not told the implement stage has a workflow, and the charter's own one-implementer-at-a- time rule [D:CHART-r6] was enforced by a mechanism the charter did not name. One word, +11 bytes: 143/150 lines, 8884/9000 bytes, caps re-run and green. SC2329 (#160). shellcheck 0.11 reports "this function is never invoked" on ops-verdict.sh's fallback_release. It is trap-reachable from NINE sites and the file already carried `# shellcheck disable=SC2317` for the same fact — 0.10's code for it. Two codes, one fact; without the second, bumping the CI pin goes red with no code change. check_lock_parity caught that the comment lives inside the LOCK BLOCK and refused the edit until ops-adopt.sh's copy matched byte-for-byte — the gate working. The tree is now clean under 0.11 with NO exclusions, so the pin can be bumped deliberately whenever the maintainer wants; this commit does not bump it. CLAUDE.md (#159 part 2, PARTIAL and said so). Four rows trimmed of restated why, every rule token and every _"…"_ citation asserted to survive the edit: base-gate 1905 -> 1595 (why is LANDMINES 0.11.13/0.11.14) handoff 897 -> 716 (evidence is in the suite's own comments) CR-strip 863 -> 730 (why is LANDMINES 0.11.15) check_bare_name 755 -> 733 That freed ~825 chars and two new coupling rows spent ~810: lib/stage.sh (#157) and a commands/<workflow>.md (#75), both previously unrecorded. Net headroom 14 of 38000. The structural problem #159 names is therefore NOT solved — it is demonstrated: this file cannot take a new coupling without an extraction that costs as much as the coupling. docs/LANDMINES.md gains the #157 narrative, which is where the stage row now points. Gates: validator all contracts hold · shell 1135 + 16 skipped · python 407 · workflows 430 · shellcheck clean at 0.11 with no exclusions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
#156) .operator/specs/ needs two allow lines, and the allowlist could not take them: the migration fires only when the version marker is ABSENT, so adding a line without bumping the marker leaves every already-initialized project silently ignoring its own specs — the stale installed-copy class through a different door. MY OWN ISSUE PROPOSED THE WRONG FIX. It reasoned the blocker was the hardcoded backup path (`.gitignore.v1.bak` would lie about holding v2 content) and asked for a generalized backup name. That accepts the premise that a version bump REPLACES. It does not have to: v1 -> v2 replaced because a blocklist and an allowlist contradict, and v3 is v2 PLUS two lines. Replacing a v2 file would silently delete every allow line the user added by hand — a destructive answer to a non-destructive change. So the arm APPENDS, no backup is involved, and the naming problem the issue was about does not arise. Both writers, each with its own arm: v1 (no marker at all) -> REPLACE behind the verified backup. Unchanged. v2 (has the v2 marker) -> APPEND `!specs/`, `!specs/*.md`, then the v3 marker LAST, so a die mid-append leaves the file unmarked and the next run retries rather than reading a half-upgraded file as done. Measured on real projects rather than asserted, all four paths: a fresh init emits v3; a v2 file carrying `!my-hand-added.md` keeps it in the LIVE file with no .bak written; a re-run appends nothing; a v1 blocklist still replaces and its content is recoverable at .v1.bak. Same four through the SessionStart hook, whose notice says the upgrade was additive — the neighbouring v1 notice tells the user their hand-written rules are GONE, which would be false here. A VACUITY I INTRODUCED AND THEN CLOSED. The additive arm greps the current marker against the live file, which satisfied #102's detection pin on its own — so deleting the DESTRUCTIVE arm's detection grep, after which a v1 blocklist is never replaced at all, shipped green the moment the arm landed (measured: test_removing_only_the_DETECTION_grep_fires went red -> green with no change to the pin). The pin now COUNTS live-target reads and requires two, because both arms genuinely need their own. A pin whose subject gains a second satisfier is vacuous for the first one. Also derived from the marker rather than hardcoded: the temp suffix, which appeared as a literal `.v2.tmp` in three regexes, so the bump unpinned the atomic write in all three at once. Mutations, each red: append -> rewrite (red in check_gitignore_parity AND in the live-file case), the hook losing v2 recognition (3 bash cases), losing the current marker, losing the previous marker. Gates: validator all contracts hold · shell 1162@1162 (1151 -> 1162) · python 408@408 (407 -> 408) · workflows 430 · compress 161 · shellcheck clean at 0.11 with no exclusions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
The cycle went brainstorm -> ??? -> plan. The only thing carrying a design
across that gap was prose the operator retyped: no file, no provenance, no
source stamp, no ledger row — so a compaction lost it and nothing noticed.
And there were TWO north stars, the BAR block's and plan.js's, with nothing
keeping them one sentence.
scripts/ops-spec.sh, installed into .operator/bin/:
--new <slug> scaffold .operator/specs/<slug>.md, O_EXCL so a second
--new refuses rather than clobbering work in progress
--check <slug> writes nothing; refuses a missing section, a north star
with no `Missed if:` clause (plan.js reads it without a
fallback), a done-criteria table still holding the
placeholder, and any open question with an empty
Resolution — an unanswered one is the interview skipped
--approve stamps Status: APPROVED @<source-state> using
ops-verdict.sh's own ladder, logs SPEC-APPROVED, and
EMITS THE BAR BLOCK from the spec
The BAR block is the point. The charter requires it by hand before the first
implementation action, and its north star and plan.js's args.northStar were
two hand-written sentences that could drift silently. Emitting the block FROM
the spec makes them one sentence by construction — pinned by a case asserting
the block quotes the spec's north star and its Missed if: clause verbatim.
Write ORDER is ops-verdict.sh's (#14): DECISIONS line, then the BAR block,
then the spec's own Status LAST. A crash between them leaves a record that
approval was attempted with the spec still DRAFT, which re-runs cleanly; the
reverse leaves an APPROVED spec no ledger knows about and nothing retries.
SPEC-APPROVED is a RECORD kind, never gated (#9): a gated kind blocks Stop
until the handoff presents it, and an approved spec is not a deviation to
answer for.
THE NEW CASE FOUND A REAL GAP IN check_decisions_schema. It verified each
kind EXISTS in the header and that a gated/record split is visible — never
which SIDE a kind is on. SPEC-APPROVED written onto the `gated` line passed
every check while wedging every session that approved a spec. That is #9's
defect exactly, and the pin could not see it. The check is now side-aware,
keyed on each line's LEADING LABEL: the shipped marker line reads "marker
(clears the gated set)", so a substring scan files it under both and reports
HANDOFF-MARK as mis-sided.
FOURTH PROJECT ROOT BLOCK, byte-identical and added to check_root_parity's
list. The python RootParityTest refused the fixture the moment the list grew,
which is the check working: three uniform-mutation cases were mutating three
of four writers, so they measured drift rather than the uniform loss they are
named for.
Mutations, each red: the BAR block no longer quoting the spec's north star,
the Missed-if requirement dropped, the walk-up removed (red in BOTH
check_root_parity and the subdirectory case — the pairing that fourth copy
needed), SPEC-APPROVED on the gated line.
Gates: validator all contracts hold · shell 1187@1187 (1162 -> 1187) · python
409@409 (408 -> 409) · workflows 430 · compress 161 · shellcheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
#157) docs/CYCLE.md §6 listed DIVERGE/SPEC/PLAN as waiting on #155: with no spec artifact there was nothing on disk to derive them from, and inventing a proxy would have been the guess the design refuses. #155 put the artifact on disk, so two of those rows are now real. stage_derive takes a sixth argument — the SPEC summary (none | draft | approved | "-" for not-looked) — and the CALLER computes it, because the lib still opens no file. That is the same division that lets the Stop hook and SessionStart share one derivation while knowing different things. THE OPT-OUT PROPERTY is the one that keeps this honest: an ABSENT .operator/specs/ reports CLEAR, never a spec stage. A project that never used the spec stage must not be told forever that it is in DIVERGE — the derivation reports where the engagement IS, never where a ceremony says it should be. Pinned, and red when `none` is folded into the draft arm. The gate's own rungs still outrank the spec ones: an open task of mine, or an unpresented decision, is what this session must deal with whatever the specs say. Both pinned. The hook's glob is bounded at 50 specs — this runs on every session start and a directory someone filled is not a reason to stall one. Past the bound the answer stays "draft", the conservative side: it prescribes finishing a spec rather than planning from one that may not be approved. Three mutations, each red: the spec rungs removed (4), the hook no longer distinguishing APPROVED (1), an absent specs dir folded into the draft arm (2, including the CLEAR control). Gates: validator all contracts hold · shell 1194@1194 (1187 -> 1194) · python 409 · workflows 430 · compress 161 · shellcheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
…view) The review panel's P0, reproduced here before the fix: `>>` appends at the byte offset the file ends at, so a .gitignore whose last line carries no terminating newline FUSED with the first appended rule — !my-hand-added.md!specs/ which destroys the user's rule and leaves `!specs/` inert, while the v3 marker still lands so the migration never retries. Both writers now terminate the file first; five cases, and the mutation removing the guard from BOTH writers turns all five red. Also from the same review: - ops-spec.sh takes the ledger lock it needs (LOCKDIR defined before the copied LOCK BLOCK — the first live --approve died on an unbound variable) and check_lock_parity holds THREE writers, compared against one reference copy: it compared ops-verdict vs ops-adopt only, so ops-spec.sh's copy was held by the content pin alone. Measured: LOCK_SPINS=100 in ops-spec.sh alone reported nothing. Red in test_third_writer_drift_fires. - the good-tree fixture gains ops-spec.sh's lock block; without it check_lock_parity returned early on the missing file and the four LockParityTest mutation cases went green against a check that never ran. - the commands case asserts tier-binding COVERAGE, not the literal ops-tiers.sh: it failed when implement.md's grant got WIDER (Bash(bash:*) per #104). Re-mutated — a command with no bash grant still fires. - ops-init.sh's refusal path, stage.sh's and spec.md's claims, and the REPLAY-CHARTER/CLAUDE.md counts corrected. Floors raised in the same commit, measured in a rootful container: bash tests/test-scripts.sh -> 1187 passed, 0 failed, 16 skipped python3 -m unittest discover -s tests -> Ran 410 tests / OK node tests/test_workflows.mjs -> 430 passed, 0 failed node tests/test_compress.mjs -> 161 passed, 0 failed shellcheck 0.11.0 scripts/ lib/ tests/ -> rc=0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
…154 review) THE VACUITY, MEASURED. The check's `if "_cr" not in code: continue` guard skipped exactly what the realistic simplification produces. On the real tree: ops-reverify.sh's loop replaced by the #139 issue's own rejected `${row%%$'\r'*}` — which truncates the row at a mid-cell CR — with `_cr` dropped from its `local` line left `validate_plugin: all contracts hold`. The loop is now required unconditionally wherever lib/caps.sh declares CAPS_MAX_CR, and the good-tree stub carries it (a stub that omits it IS the deletion). Red in CrStripParityTest. The review reported this as "deleting the whole strip from ops-reverify.sh leaves all contracts hold". That narrower mutation — the loop gone, the `local … _cr=0` line kept — FIRES even before this fix, twice. The case is written against the mutation that actually escapes, and says so. CHANGELOG + 0.12.0. The release gate refuses a non-empty [Unreleased] at the tagged version, so this branch's work gets its own section: the spec stage (#155), the implement workflow (#158), the command surface (#75), the stage derivation (#157), the additive v3 gitignore (#156), the charter wording (#159) and SC2329 (#160), plus this review round's four fixes. Floors: FLOOR_python 410 -> 411 (the new case). CLAUDE.md back under its 38000 cap by trimming three rows of restated why; two rows corrected while there — stage.sh's ladder is BLOCKED>IMPLEMENT>HANDOFF>SPEC>PLAN>CLEAR (the SPEC and PLAN rungs #155 added were never recorded), and the CYCLE.md provenance bullet called the spec stage unbuilt after #155 built it. Gates, this tree: validator -> all contracts hold python -> 411 cases (floor 411) shell -> 1203 cases (floor 1203) workflows -> 430 cases (floor 430) compress -> 161 cases (floor 161) shellcheck 0.11.0, no exclusions -> rc=0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
There are a couple of correctness issues in new/changed checks/tests (notably the open-questions validation in ops-spec.sh and a dispatch “no model key” assertion in tests/test_workflows.mjs) that should be fixed before approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This PR ships v0.12.0 by completing the engagement cycle’s previously missing “spec” and “implement” stages, adding the operator-facing command surface to reach workflows, and strengthening validation/parity checks so the new artifacts and writers stay consistent with the existing evidence gate.
Changes:
- Add a tracked spec artifact (
ops-spec.sh) with approval stamping into both ledgers under the shared lock, plus.operator/.gitignorev3 additive migration to track.operator/specs/. - Add
workflows/implement.js(strictly serial implement stage) and extenddispatch.jsto resolve byargs.model→args.tier→ “no model override”. - Add a command per workflow, stage derivation in SessionStart via
scripts/lib/stage.sh, and the associated validator/tests/docs/version updates.
| File | Description |
|---|---|
| workflows/implement.js | New serial implement workflow with packet validation and schema-guided reporting. |
| workflows/dispatch.js | Tier-based model resolution and “no override” fallback (omit model key). |
| tests/test_workflows.mjs | New runtime capture fields + expanded tests for dispatch ladder and implement workflow. |
| tests/floors.env | Raised suite floors to match added/expanded test coverage. |
| templates/OPERATOR.md | Charter updated to include implement among orchestrated primitives. |
| templates/DECISIONS-header.md | Adds SPEC-APPROVED as a record kind in the DECISIONS schema. |
| scripts/validate_plugin.py | New/updated validator checks for implement packet, decisions kind “side”, gitignore v3 parity, lock/root parity extensions. |
| scripts/ops-verdict.sh | Adds ShellCheck SC2329 suppression for trap-reached fallback_release. |
| scripts/ops-spec.sh | New spec artifact CLI: scaffold/check/approve + lock-disciplined ledger writes. |
| scripts/ops-sessionstart-hook.sh | Adds v2→v3 gitignore append upgrade + derived stage line in the SessionStart banner. |
| scripts/ops-install-set.sh | Installs ops-spec.sh into .operator/bin/ set. |
| scripts/ops-init.sh | .operator/.gitignore v3 emission + v2→v3 additive append upgrade path. |
| scripts/ops-adopt.sh | Adds SC2329 suppression alongside existing unreachable-function suppression rationale. |
| scripts/lib/stage.sh | New pure/report-only stage derivation helper for banner guidance. |
| README.md | Updates docs to reflect spec artifacts, implement workflow, expanded commands, and gitignore semantics. |
| docs/REPLAY-CHARTER.md | Updates CLI list and replay guidance for the new installed CLI. |
| docs/LANDMINES.md | Adds rationale entries for implement-tier reachability, serialization pinning, and stage derivation. |
| docs/HANDOUT.md | Repairs outdated references from docs/spec/ to docs/TAGS.md. |
| docs/CYCLE.md | New design rationale/spec for cycle, now reflecting built stages. |
| CONTRIBUTING.md | Updates rationale location references (docs/ vs removed docs/spec/). |
| commands/tiers.md | Documents dispatch tier ladder and the “no model override” behavior. |
| commands/spec.md | New /cc-operator:spec command guidance for writing/checking/approving specs. |
| commands/review.md | New per-workflow command wrapper resolving tiers automatically for review. |
| commands/plan.md | New per-workflow command wrapper with approved-spec gating guidance. |
| commands/implement.md | New per-workflow command wrapper for serial implement dispatch. |
| commands/debate.md | New per-workflow command wrapper resolving tiers automatically for debate. |
| commands/crawl.md | New per-workflow command wrapper resolving tiers automatically for crawl. |
| commands/brainstorm.md | New per-workflow command wrapper resolving tiers automatically for brainstorm. |
| CLAUDE.md | Updates coupling table and invariants to include new stage/packet/stage lib rules. |
| CHANGELOG.md | Adds 0.12.0 release notes summarizing cycle completion and related fixes. |
| .claude-plugin/plugin.json | Bumps plugin version to 0.12.0. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
) FINDING 2 — REAL, and it made the pin vacuous. The dispatch no-override assertion read `"model" in dFallCall`, which asks the test stub's RECORD. The stub writes `model: opts.model` unconditionally, so that property ALWAYS exists and the check collapsed to `model === undefined` — while `hasModelKey` (`"model" in opts`, taken at the call, added for exactly this distinction) went unused. Its own comment claimed it asked the options object. Measured: dispatch.js sending `{ model: undefined }` on that rung kept the node suite at 430 passed, 0 failed. Now reads `hasModelKey === false`, the control asserts `=== true`, and the same mutation is 1 red. FINDING 1 — HALF REAL. The premise is wrong: the skeleton's headers are `| Question | Resolution | Decided by |` and `|---|---|---|`, both matched by the existing filters, and a fresh skeleton reports no open-questions problem (measured). The other half holds: `grep -c '| *|'` matches an empty cell ANYWHERE in the row, so `| Can we X? | Yes | |` — answered, nobody named — was refused as an "empty Resolution cell". A true refusal under a false name is worse than no message: it sends the operator to fix a cell that is already correct. Now cell-addressed. Resolution and 'Decided by' are separate conditions with separate messages; a row with fewer than three cells is malformed and counts as unanswered, failing CLOSED like every other refusal here. Six cases (all five shapes plus a header/separator control); the mutation restoring the substring scan is 3 red — the 'Decided by' message case and both malformed-row cases. The rc-only unattributed case stays green under that mutation ON PURPOSE: the old scan refused that row too, just under the wrong name, so only a message assertion can see the defect. Floors: FLOOR_shell 1203 -> 1209. Finding 2 needed none (an assertion changed, no case added). Gates, this tree: validator -> all contracts hold python -> 411 cases (floor 411) shell -> 1209 cases (floor 1209) workflows -> 430 cases (floor 430) compress -> 161 cases (floor 161) shellcheck 0.11.0, no exclusions -> rc=0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun

What
The engagement cycle gains the two stages it never had — a spec artifact and an implement workflow — plus the command surface that reaches them, the stage derivation that tells a session where it stands, and the gitignore migration the spec artifact needed. Ships as 0.12.0. Design rationale:
docs/CYCLE.md.b7e4e3fdocs/CYCLE.md— the spec stage (§2–§4), plus three danglingdocs/spec/refs repaired445f5707764d00dispatch.jsdeclines to choose rather than promoting6cff0bcdispatch.jsresolution ladder5beb34bworkflows/implement.js+check_implement_packetb223b2df6d9b6fscripts/lib/stage.sh+ the SessionStart banner (#157)8631e8d9e63e8fstage.shshipped without (CI red onf6d9b6f)beb2fb925c4cafbeb1c36scripts/ops-spec.sh, the spec artifact and its approval stamp (#155)4c7710144cba6f3fed949check_cr_strip_parity's vacuity, CHANGELOG, 0.12.0f025c04Gap 1 — the spec stage had no artifact (§2–§4) — BUILT (#155)
The handoff from divergence to planning was a source comment in
workflows/brainstorm.jspointing atdocs/spec/, which does not exist (emptied in 0.11.9; three tracked refs still pointed at it — the F61 class, regressed, repaired here). Soargs.specwas a string in the operator's context: no file, no provenance, no stamp, no ledger row, nothing that survives a compaction.scripts/ops-spec.shwrites.operator/specs/<slug>.md(--new), checks it against the schema (--check) and stamps approval (--approve). The stamp writes aSPEC-APPROVEDrow to DECISIONS.md and the BAR block to VERDICTS.md, both under the SAME.operator/.lockthatops-verdict.shandops-adopt.shtake, in that order — so a concurrent verdict row lands whole, never inside the BAR block.check_root_paritynow holds FOUR copies of the project-root block,check_lock_parityTHREE writers.Gap 2 — the implement stage had no workflow (§5) — BUILT (#158)
One of the four canonical tiers, the tier the implementer is bound to, dispatched by nothing. Every stage that only READS ran as a workflow with a tier map; the one stage that WRITES CODE was a plain
Agentcall against a hardcoded alias.dispatch.js— the ladder isargs.model→args.tier→ nomodelkey at all.model || JUDGMENTdispatched the IMPLEMENT-boundmechanicon the judgment default and logged that it had: honest, not correct. Tier validation went lazy, which is required, not incidental — with four tiers nameable and one reached per call, an eager loop resurrects what PR #76 sequence: single source of truth by removing responsibilities, not policing copies #78 removed.workflows/implement.js— refuses an incomplete packet with zero agents dispatched, naming every defect at once; serial, structurally (noparallel(in the file, because one implementer at a time is[D:CHART-r6]and prose does not hold it); four-status protocol as a schema enum,changedas an array; stops on a dead seat; returns the unioned CHANGED and stops at the sandbox boundary.check_implement_packet—PACKET_FIELDSis the fourth hand-copy of the charter's packet and the first in code: both directions pinned, plus the application, because a field required by the refusal and dropped before the prompt is worse than one never required.Rung 3's premise is measured (2026-09-21). Two seats, same
agentType(frontmattermodel: opus):omittedmodelkeyclaude-opus-5explicit"model": "haiku"claude-haiku-4-5-20251001Gap 3 — a command per workflow (#75) — BUILT
commands/{spec,brainstorm,plan,implement,review,crawl,debate}.md, each resolving the bindings withops-tiers.sh --jsonand passing the line through. That removes the hand-paste that is #55 at the call site.dispatchgets none on purpose. Pinned per command: the resolution, the grant covering the prescription (#104), and that each dispatches its own workflow — coverage, not equality: the first version demanded the literalops-tiers.shinallowed-toolsand failed whenimplement.md's grant got wider.The artifact route and the tutorial stay open on #75, explicitly deferred.
Gap 4 — nothing could say which stage an engagement is in (#157) — BUILT
scripts/lib/stage.shis a pure function — it opens no file, so it adds no reader, no byte cap, no second copy of the partition rule. That is what lets the Stop hook (every scan) and SessionStart (only the pending one) share one derivation: they differ in what they KNOW, and the unknown input gets a narrower answer, never a guess. RungsBLOCKED > IMPLEMENT > HANDOFF > SPEC > PLAN > CLEAR, each making the next unreachable; a foreign task is reported, never a stage; an unscanned deviation gate (-) is said, never assumed clean. The result goes in the SessionStart banner — the only channel a session has after a compaction.No
ops-stage.shCLI: the derivation ships where it is consumed.Gap 5 — the gitignore allowlist could not be extended (#156) — BUILT
v1→v2 REPLACES, because a blocklist and an allowlist are contradictory schemes and merging them yields neither. v2→v3 APPENDS, because v3 only adds
specs/to a scheme v2 already established — a replace there would have destroyed every hand-added rule. Both writers carry it;check_gitignore_paritypins both halves, and its live-target detection now counts reads (≥2): the additive arm's own grep satisfied the single-read pin, so deleting the destructive arm's detection shipped green.Review rounds — what they found
Round 1, P0, reproduced before fixing: the v3 append FUSED with an unterminated last line.
>>appends at the byte offset the file ends at, so a.gitignorewhose last line carried no terminating newline came back as— the user's rule destroyed,
!specs/inert, and the v3 marker still landing so the migration never retries. Both writers terminate the file first; five cases, and the mutation removing the guard from BOTH writers turns all five red.Also round 1:
ops-spec.shtook the lock it needed (LOCKDIRwas undefined before the copied LOCK BLOCK — the first live--approvedied on an unbound variable);check_lock_parityextended to three writers; the ops-init refusal path; three prose claims corrected.Round 2, three vacuities in this PR's own gates:
check_lock_paritycomparedops-verdict.shagainstops-adopt.shonly, soops-spec.sh's copy was held by the content pin alone — and the content pin passes anything that still looks like a lock. Measured:LOCK_SPINS=100inops-spec.shalone reported nothing. Parity now runs against one reference copy, so a fourth writer cannot drift unseen either.ops-spec.sh's lock block, socheck_lock_parityreturned early on the missing file and all four LockParityTest mutation cases went green against a check that never ran — the "Mutation-checked" does not record WHICH gate went red — a pin can be vacuous while its mutation is caught by a different suite #111 shape, inside the suite written to prevent it.check_cr_strip_parity'sif "_cr" not in code: continueskipped exactly what the realistic simplification produces. Measured on the real tree:ops-reverify.sh's loop replaced by the CR handling after #136: \r\r\n still fails the cap OPEN, check_cell admits a mid-cell CR, and the byte cap reads 1.2% loose #139 issue's own rejected${row%%$'\r'*}with_crdropped from itslocalline leftvalidate_plugin: all contracts hold. (The narrower mutation the review reported — the loop deleted, thelocalline kept — fires even without this fix; the case is written against the one that actually escapes, and says so.)Round 3 (Copilot), two findings — one real, one whose premise does not hold. Both verified by measurement before anything was changed; both threads answered with those measurements and resolved.
"model" in dFallCall— the test stub's RECORD, which always carries amodelproperty because the stub writes one — so it collapsed tomodel === undefined, while thehasModelKeyrecorded for exactly this distinction went unused. Its own comment claimed it asked the options object. Measured:dispatch.jssending{ model: undefined }on that rung kept the node suite at 430 passed, 0 failed. NowhasModelKey === false, control asserts=== true; same mutation → 1 red.||headers are miscounted) does not hold: the skeleton writes| Question | Resolution | Decided by |and|---|---|---|, both matched by the existing filters, and a fresh skeleton reports no open-questions problem. The other half does:grep -c '| *|'matches an empty cell ANYWHERE in the row, so| Can we X? | Yes | |— answered, nobody named — was refused as an "empty Resolution cell". A true refusal under a false name is worse than no message: it sends the operator to fix a cell that is already correct. Now cell-addressed, withDecided bygiven its own message and a sub-three-cell row failing CLOSED as unanswered.Mutation evidence
Every pin driven red before it was believed, naming the gate:
model || TIERS.JUDGMENTargs.tiercheck_implement_packet--showinstead of--json, a droppedWorkflowgrant, a deleted commandLOCK_SPINS=100inops-spec.shalonetest_third_writer_drift_fires_crgone fromops-reverify.shentirelytest_a_copy_that_drops_the_counter_ENTIRELY_fires{ model: undefined }on dispatch's no-override rungf025c04)| *|substring scan restored inops-spec.shSerialization is pinned by a source scan with a
brainstorm.jscontrol — the stub runsparallel()thunks sequentially, so a concurrency counter would be vacuous.Gates — measured on head
f025c04CI on this head:
validaterun 341 success,base-gaterun 31 success — so the tree is also clean at CI's pinned shellcheck 0.10.Every floor raised in the same commit as its cases, with the measurement written into
tests/floors.env.validatewent red onf6d9b6f/8631e8dfor a real lint of mine —stage.shmissing theSC2034directive its three sibling libs carry — reproduced locally and fixed in9e63e8f.Scope
templates/OPERATOR.mdchanges by one word. It stayed untouched through the first nine commits;beb2fb9addsimplementto ORCHESTRATED MODE's primitives sentence, because the charter was listing the primitives while omitting the workflow that enforces its own one-implementer-at-a-time rule. 143 lines / 8884 bytes against the 150 / 9000 caps, re-measured on this head. No workflow gained a tool it cannot have.CLAUDE.mdis structurally full and this PR demonstrates it rather than solving it (#159): three new coupling rows and one amendment forced a second extraction to stay under the 38000 cap. Two rows were corrected while there —stage.sh's ladder never recorded the SPEC and PLAN rungs, and the provenance bullet called the spec stage unbuilt after #155 built it.Follow-ups
#159 the CLAUDE.md headroom decision · #75 the artifact route and the tutorial (both explicitly deferred) ·
docs/CYCLE.md§4's plan gate stays unbuilt: the spec artifact is stamped, but nothing yet refuses aplanrun whose spec is unapproved. #158, #160, #155, #156, #157 close with this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01FPU5aDfukCkFX5rbLdJgun