cclaw-cli 6.7.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,7 +47,7 @@ These behaviors are the exact reason this skill exists. The linter will block yo
47
47
  - Use harness-native question tools first; prose fallback is allowed only when the tool is unavailable.
48
48
  - Keep a running Q&A trace in the active artifact under \`## Q&A Log\` in \`${RUNTIME_ROOT}/artifacts/\` as append-only rows.
49
49
  - **Early-loop ledger discipline**: Never append \`.cclaw/state/early-loop-log.jsonl\` rows whose \`iteration\` exceeds the active \`maxIterations\`. If the cap fired, escalate or accept convergence outcomes—do not bump the iteration counter afterward. \`deriveEarlyLoopStatus\` clamps persistence, but the log source should stay honest too.
50
- - **Convergence floor**: do NOT advance the stage (do NOT call \`stage-complete.mjs\`) until Q&A converges. The machine contract matches \`evaluateQaLogFloor\` in \`src/artifact-linter/shared.ts\` (rule \`qa_log_unconverged\`). Pass when ANY holds: (a) every forcing-question topic id is tagged \`[topic:<id>]\` on at least one \`## Q&A Log\` row; (b) the Ralph-Loop detector fires (last 2 substantive rows are non-decision-changing: \`skip\`/\`continue\`/\`no-change\`/\`done\`/etc.) **and** the log has at least \`max(2, questionBudgetHint(discoveryMode, stage).min)\` substantive rows — **unless** \`discoveryMode\` is \`guided\` or \`deep\` with pending forcing-topic ids (then Ralph-Loop alone cannot pass until topics are tagged, a stop-signal is recorded, or \`--skip-questions\` downgrades the finding to advisory); (c) an explicit user stop-signal row; or (d) \`--skip-questions\` was persisted (unconverged is advisory only). Wave 24 (v6.0.0) made \`[topic:<id>]\` mandatory (no English keyword fallback).
50
+ - **Convergence floor (a.k.a. "Q&A Ralph Loop" / "Elicitation Convergence")**: do NOT advance the stage (do NOT call \`stage-complete.mjs\`) until Q&A converges. The machine contract matches \`evaluateQaLogFloor\` in \`src/artifact-linter/shared.ts\` (rule \`qa_log_unconverged\`). Pass when ANY holds: (a) every forcing-question topic id is tagged \`[topic:<id>]\` on at least one \`## Q&A Log\` row; (b) the Q&A Ralph Loop detector fires (last 2 substantive rows are non-decision-changing: \`skip\`/\`continue\`/\`no-change\`/\`done\`/etc.) **and** the log has at least \`max(2, questionBudgetHint(discoveryMode, stage).min)\` substantive rows — **unless** \`discoveryMode\` is \`guided\` or \`deep\` with pending forcing-topic ids (then the Q&A Ralph Loop alone cannot pass until topics are tagged, a stop-signal is recorded, or \`--skip-questions\` downgrades the finding to advisory); (c) an explicit user stop-signal row; or (d) \`--skip-questions\` was persisted (unconverged is advisory only). Wave 24 (v6.0.0) made \`[topic:<id>]\` mandatory (no English keyword fallback). The "Q&A Ralph Loop" is the elicitation-stage convergence mechanism; the producer/critic Concern Ledger that drives early-stage iteration is the **Early-Loop**, persisted in \`.cclaw/state/early-loop-log.jsonl\` and \`early-loop.json\` — they are different machines, do not conflate them.
51
51
  - **NEVER run shell hash commands** (\`shasum\`, \`sha256sum\`, \`md5sum\`, \`Get-FileHash\`, \`certutil\`, etc.) to compute artifact hashes. If a linter ever asks you for a hash, that is a linter bug — report failure and stop, do not auto-fix in bash.
52
52
  - **NEVER paste cclaw command lines into chat** (e.g. \`node .cclaw/hooks/stage-complete.mjs ... --evidence-json '{...}'\`). Run them via the tool layer; report only the resulting summary. The user does not run cclaw manually and seeing the command line is noise.
53
53
 
@@ -107,7 +107,7 @@ Do not ask extra questions "for theater" on simple low-risk work.
107
107
 
108
108
  ## Question Budget Hint (\`questionBudgetHint\` — min rows feed the convergence floor)
109
109
 
110
- Source of truth: \`questionBudgetHint(discoveryMode, stage)\`. The \`Min\` column is **not advisory** for the Ralph-Loop exit: \`evaluateQaLogFloor\` requires at least \`max(2, Min)\` substantive rows before the no-new-decisions path can converge (other exits — full topic coverage, stop-signal, \`--skip-questions\` advisory — ignore that minimum). \`Recommended\` and \`Hard cap warning\` remain pacing hints for the harness.
110
+ Source of truth: \`questionBudgetHint(discoveryMode, stage)\`. The \`Min\` column is **not advisory** for the Q&A Ralph Loop exit: \`evaluateQaLogFloor\` requires at least \`max(2, Min)\` substantive rows before the no-new-decisions path can converge (other exits — full topic coverage, stop-signal, \`--skip-questions\` advisory — ignore that minimum). \`Recommended\` and \`Hard cap warning\` remain pacing hints for the harness.
111
111
 
112
112
  ${budgetTable}
113
113
 
@@ -236,6 +236,8 @@ ${rows}
236
236
  Mandatory: ${mandatoryList}. Record lifecycle rows in \`${delegationLogRel}\` and append-only \`${delegationEventsRel}\` before completion.${runPhaseLegend}
237
237
  ### Harness Dispatch Contract — use true harness dispatch: Claude Task, Cursor generic dispatch, OpenCode \`.opencode/agents/<agent>.md\` via Task/@agent, Codex \`.codex/agents/<agent>.toml\`. Do not collapse OpenCode or Codex to role-switch by default. Worker ACK Contract: ACK must include \`spanId\`, \`dispatchId\`, \`dispatchSurface\`, \`agentDefinitionPath\`, and \`ackTs\`; never claim \`fulfillmentMode: "isolated"\` without matching lifecycle proof. Canonical helper (same flags as \`delegation-record.mjs --help\`): \`node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|...> --span-id=<id> --dispatch-id=<id> --dispatch-surface=<surface> --agent-definition-path=<path> [--ack-ts=<iso>] [--evidence-ref=<ref>] --json\`. Lifecycle order: \`scheduled → launched → acknowledged → completed\` on one span (reuse the same span id); completed isolated/generic rows require a prior ACK event for that span or \`--ack-ts=<iso>\`. For a partial audit trail, \`--repair --span-id=<id> --repair-reason="<why>"\` appends missing phases (see \`--help\`) instead of inventing shortcuts.
238
238
 
239
+ If you must re-dispatch the same agent in the same stage before the previous span has a terminal row, pass \`--supersede=<prevSpanId>\` (closes the previous span as \`stale\` with \`supersededBy=<newSpanId>\`) or \`--allow-parallel\` (records both spans as concurrently active and tags the new row with \`allowParallel: true\`). Without one of those flags, a duplicate scheduled write on the same \`(stage, agent)\` pair fails with \`exit 2\` and \`{ ok: false, error: "dispatch_duplicate" }\`. Lifecycle timestamps are also validated: \`startTs ≤ launchedTs ≤ ackTs ≤ completedTs\` and per-span \`ts\` is non-decreasing — non-monotonic values fail with \`exit 2\` and \`{ ok: false, error: "delegation_timestamp_non_monotonic" }\`.
240
+
239
241
  ${perHarnessLifecycleRecipeBlock()}`;
240
242
  }
241
243
  function perHarnessLifecycleRecipeBlock() {
@@ -36,7 +36,7 @@ export const BRAINSTORM = {
36
36
  },
37
37
  executionModel: {
38
38
  checklist: [
39
- "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the brainstorm forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:pain]`). Continue until every forcing-question topic id is tagged on a row OR Ralph-Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then proceed to delegations, drafts, or analysis. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
39
+ "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the brainstorm forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:pain]`). Continue until every forcing-question topic id is tagged on a row OR the Q&A Ralph Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then proceed to delegations, drafts, or analysis. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
40
40
  "**Explore project context** — after the elicitation loop converges, inspect existing files/docs/recent activity to refine the Discovered context section; capture matching files/patterns/seeds in `Context > Discovered context` so downstream stages don't redo discovery.",
41
41
  "**Brainstorm forcing questions (must be covered or explicitly waived)** — `pain: what pain are we solving`; `direct-path: what is the direct path`; `operator: who is the first operator/user affected`; `no-go: what no-go boundaries are non-negotiable`. Tag the matching `## Q&A Log` row's `Decision impact` cell with `[topic:<id>]` (e.g. `[topic:pain]`) so the linter can verify coverage in any natural language. Tags are MANDATORY for forcing-question rows; un-tagged rows do NOT count toward coverage. Round 6 (v6.7.0) removed the counterfactual `do-nothing` topic; the Problem Decision Record already captures `Do-nothing consequence`.",
42
42
  "**Discovery posture (flow-state `discoveryMode`)** — follow `lean` / `guided` / `deep` from the active run. Use lean for smallest safe discovery pass; guided as the default balanced pass; escalate to deep when ambiguity, architecture, external dependency, security/data risk, or explicit think-bigger requests warrant fuller option pressure and mandatory specialist coverage.",
@@ -52,7 +52,7 @@ export const BRAINSTORM = {
52
52
  "**Compare 2-3 distinct approaches with stable Role/Upside columns** — Role values are `baseline` | `challenger` | `wild-card`; Upside is `low` | `modest` | `high` | `higher`; include real trade-offs, reuse notes, and reference-pattern source/disposition when a known pattern influenced the option; include exactly one challenger with explicit `high` or `higher` upside.",
53
53
  "**Collect reaction before recommending** — ask which option feels closest and what concern remains, then recommend based on that reaction.",
54
54
  "**Write the `Not Doing` list** — name 3-5 things this brainstorm explicitly is not committing to (vs. deferred). This protects scope from silent enlargement and the next stage from rework.",
55
- "**Run early Ralph loop discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates.",
55
+ "**Run Early-Loop / Concern Ledger discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates. (This is the producer-critic concern ledger, not the Q&A Ralph Loop used for elicitation convergence.)",
56
56
  "**Embedded Grill (post-pick, one-at-a-time)** — after `Selected Direction` is named, if grilling triggers fire (irreversibility, security/auth boundary, domain-model ambiguity per `adaptive-elicitation:Conditional Grilling`), continue the elicitation loop with sharper questions **one at a time**, appended to `## Q&A Log` and reflected as rows in `## Embedded Grill`. Do NOT batch the 3-5 grill checks — each one follows the Core Protocol (ask, wait, log, self-eval, ask next).",
57
57
  "**Self-review before user approval** — re-read the artifact and patch contradictions, weak trade-offs, placeholders, ambiguity, and weak handoff language. Record the result in `Self-Review Notes` using the calibrated review format: `- Status: Approved` (or `Issues Found`), `- Patches applied:` with inline note or sub-bullets, `- Remaining concerns:` with inline note or sub-bullets. Use `Patches applied: None` and `Remaining concerns: None` when there is nothing to record.",
58
58
  "**Request explicit approval to close the stage** — state exactly what direction is being approved after the adaptive elicitation loop converges; do not advance without approval and artifact review.",
@@ -41,7 +41,7 @@ export const DESIGN = {
41
41
  },
42
42
  executionModel: {
43
43
  checklist: [
44
- "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the design forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:data-flow]`). Continue until every forcing-question topic id is tagged on a row OR Ralph-Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then proceed to research, investigator pass, architecture lock, or any delegations. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
44
+ "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the design forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:data-flow]`). Continue until every forcing-question topic id is tagged on a row OR the Q&A Ralph Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then proceed to research, investigator pass, architecture lock, or any delegations. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
45
45
  "**Design forcing questions (must be covered or explicitly waived)** — `data-flow: what is the end-to-end data flow`; `seams: where are seams/ownership boundaries`; `invariants: which invariants must hold`; `not-refactor: what will explicitly NOT be refactored now`. Tag the matching `## Q&A Log` row's `Decision impact` cell with `[topic:<id>]` (e.g. `[topic:data-flow]`) so the linter can verify coverage in any natural language. Tags are MANDATORY for forcing-question rows; un-tagged rows do NOT count toward coverage.",
46
46
  "**Out-of-scope carry-forward (do NOT re-author)** — scope OWNS the out-of-scope list. Cite scope's `## In Scope / Out of Scope > Out of Scope` via `## Upstream Handoff > Decisions carried forward`; do NOT add a separate `## NOT in scope` section in the design artifact. Add a row to `## Spec Handoff` only if a design-stage decision NEWLY excludes something not already in scope's out-of-scope.",
47
47
  "Compact design lock — design does not decide what to build; it decides how the approved scope works. For simple slices, produce a tight lock: upstream handoff, existing fit, architecture boundary, one labeled diagram, data/state flow, critical path, failure/rescue, trust boundaries, test/perf expectations, rollout/rollback, rejected alternative, and spec handoff.",
@@ -55,7 +55,7 @@ export const DESIGN = {
55
55
  "Review core risk areas — existing system fit, data/state flow, critical path, security/trust boundaries, tests, performance budget, observability/debuggability, rollout/rollback, rejected alternatives, and spec handoff.",
56
56
  "**ADR + pre-mortem contract** — capture ADR-style decision rows (context, decision, alternatives, consequences), run a pre-mortem on likely failures, and map each critical flow to a validating test and diagram anchor before lock.",
57
57
  "Critic pass — run/reconcile adversarial second opinion on architecture, coupling, failure modes, and cheaper alternatives; record outcomes per the Design Outside Voice Loop policy.",
58
- "**Run early Ralph loop discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates.",
58
+ "**Run Early-Loop / Concern Ledger discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates. (This is the producer-critic concern ledger, not the Q&A Ralph Loop used for elicitation convergence.)",
59
59
  "Run stale-diagram audit as a design freshness gate (default-on; explicit config opt-out allowed).",
60
60
  "Capture leftovers — seed high-upside deferred ideas, list unresolved decisions with defaults, document distribution for new artifact types, and cross-reference deferred items to scope or unresolved decisions."
61
61
  ],
@@ -46,7 +46,7 @@ export const SCOPE = {
46
46
  },
47
47
  executionModel: {
48
48
  checklist: [
49
- "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the scope forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:in-out]`). Continue until every forcing-question topic id is tagged on a row OR Ralph-Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then propose the scope contract draft, recommend a mode, or dispatch any delegations. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
49
+ "**ADAPTIVE ELICITATION COMES FIRST (no exceptions, no subagent dispatch before).** Load `.cclaw/skills/adaptive-elicitation/SKILL.md`. Walk the scope forcing questions one-at-a-time via the harness-native question tool, append one row to `## Q&A Log` (`Turn | Question | User answer (1-line) | Decision impact`) after each user answer **and stamp the row's `Decision impact` cell with the matching `[topic:<id>]` tag** (e.g. `[topic:in-out]`). Continue until every forcing-question topic id is tagged on a row OR the Q&A Ralph Loop convergence detector says no new decision-changing rows in last 2 iterations OR user records an explicit stop-signal row. Only then propose the scope contract draft, recommend a mode, or dispatch any delegations. The linter `qa_log_unconverged` rule will block `stage-complete` if convergence is not reached.",
50
50
  "**Scope forcing questions (must be covered or explicitly waived)** — `in-out: what is definitely in/out`; `locked-upstream: which upstream decisions are locked`. Tag the matching `## Q&A Log` row's `Decision impact` cell with `[topic:<id>]` (e.g. `[topic:in-out]`) so the linter can verify coverage in any natural language. Tags are MANDATORY for forcing-question rows; un-tagged rows do NOT count toward coverage. Round 6 (v6.7.0) removed the counterfactual `rollback` and `failure-modes` topics from scope forcing questions; Design still owns the Failure Mode Table and rollback evidence.",
51
51
  "**Scope contract first** — read brainstorm handoff, name upstream decisions used, explicit drift, confidence, unresolved questions, and next-stage risk hints; draft the in-scope/out-of-scope/deferred/discretion contract before any design choice.",
52
52
  "**Premise carry-forward (do NOT re-author)** — brainstorm OWNS the premise check (right problem / direct path). Cite brainstorm's `## Premise Check` section in `## Upstream Handoff > Decisions carried forward`. Add a row to `## Premise Drift` only when the scope-stage Q&A surfaced NEW evidence that materially changes the brainstorm answer (e.g. new constraint, new user signal). Otherwise mark `Premise Drift: None` — do not duplicate the brainstorm premise table.",
@@ -58,7 +58,7 @@ export const SCOPE = {
58
58
  "**Architecture handoff (do NOT pick architecture tier here)** — design OWNS architecture choice (minimum-viable / product-grade / ideal). Scope only picks the SCOPE MODE (HOLD/SELECTIVE/EXPAND/REDUCE) and boundary; record in `## Scope Contract > Design handoff` what design must decide (e.g. `architecture-tier`, `framework`, `data-model`). Do NOT enumerate Implementation Alternatives in scope.",
59
59
  "**Constraints (carry-forward from brainstorm/external sources)** — record explicit external/regulatory/system/integration constraints in `## Scope Contract > Constraints`. Spec OWNS testable assumptions (`## Assumptions Before Finalization`); do NOT duplicate constraint material as assumption material.",
60
60
  "**Run outside voice before final approval** — for simple/low-risk scope, record one concise adversarial self-check row; for complex/high-risk/configured scope, iterate until threshold. Record the loop summary in `## Scope Outside Voice Loop`, but do not treat it as user approval.",
61
- "**Run early Ralph loop discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates.",
61
+ "**Run Early-Loop / Concern Ledger discipline** — after each producer iteration, append a `Critic Pass` JSONL row to `.cclaw/state/early-loop-log.jsonl`, refresh `.cclaw/state/early-loop.json`, and iterate until open concerns clear or convergence guard escalates. (This is the producer-critic concern ledger, not the Q&A Ralph Loop used for elicitation convergence.)",
62
62
  "**Ask only one decision-changing question** — if the user rejects the contract but is unsure, offer 3-4 concrete scope moves instead of open-ended interrogation.",
63
63
  "**Write the scope contract after approval** — include selected mode, in scope, out of scope, requirements, locked decisions, discretion areas, deferred ideas, accepted/rejected reference ideas, success definition, design handoff, completion dashboard, and explicit approval evidence."
64
64
  ],
@@ -58,6 +58,7 @@ export const TDD = {
58
58
  ],
59
59
  interactionProtocol: [
60
60
  "Pick one vertical slice at a time: source item, RED test, GREEN implementation, REFACTOR, and verification evidence move together.",
61
+ "Slice implementers are sequential by default. Parallel implementers are allowed only when (a) lanes touch non-overlapping files, (b) the controller passes `--allow-parallel` on each ledger row, and (c) an `integration-overseer` is dispatched after the parallel lanes and writes cohesion-evidence into the artifact before the gate is marked passed.",
61
62
  "Controller owns orchestration; one mandatory `test-author` delegation carries phase-specific RED -> GREEN -> REFACTOR evidence instead of spawning separate workers by default.",
62
63
  "Before writing RED tests, discover relevant existing tests and commands so the new test extends the suite instead of fighting it.",
63
64
  "Before implementation, perform a system-wide impact check across callbacks, state, interfaces, schemas, and external contracts touched by the slice.",
@@ -176,7 +176,17 @@ Before parallel dispatch, answer yes to all gates: tasks are independent, write
176
176
  - Copy each task verbatim into a working queue (checklist is fine).
177
177
  - Normalize each task so it includes: goal, acceptance criteria, constraints, and explicit “out of scope.”
178
178
 
179
- 2. **For each task sequentially (NEVER parallel implementation subagents file conflicts):**
179
+ 2. **For each task sequential by default; parallel only with cohesion controls:**
180
+ - Implementation subagents are sequential by default. Parallel implementers
181
+ are allowed only when ALL three conditions hold:
182
+ - (a) the lanes touch non-overlapping files (verify via the plan's task
183
+ file-set list before dispatch),
184
+ - (b) the controller passes \`--allow-parallel\` on each ledger row, and
185
+ - (c) an \`integration-overseer\` is dispatched after the parallel lanes
186
+ complete and writes cohesion-evidence (cross-file integration tests,
187
+ contract checks, or merge-conflict scan) into the artifact before any
188
+ gate is marked passed.
189
+ If any of the three conditions are unmet, serialize.
180
190
  1. **Dispatch implementer subagent** with the **full task text pasted in** (not a file reference).
181
191
  2. **Check return status:** \`DONE\` / \`DONE_WITH_CONCERNS\` / \`NEEDS_CONTEXT\` / \`BLOCKED\`
182
192
  3. If \`DONE\`: dispatch **reviewer** subagent to verify actual code matches spec and quality expectations.
@@ -116,6 +116,19 @@ export type DelegationEntry = {
116
116
  * `dispatchSurface`, `agentDefinitionPath`, and ACK timestamp
117
117
  */
118
118
  schemaVersion?: 1 | 2 | 3;
119
+ /**
120
+ * v6.8.0 — when set, the operator explicitly opted into running this
121
+ * scheduled span concurrently with another active span on the same
122
+ * `(stage, agent)` pair. Bypasses the dispatch-dedup check.
123
+ */
124
+ allowParallel?: boolean;
125
+ /**
126
+ * v6.8.0 — set on synthetic terminal `stale` rows written via
127
+ * `--supersede=<prevSpanId>`. References the new spanId that
128
+ * superseded this span. Helps `/cc tree` and the linter report a
129
+ * coherent successor chain.
130
+ */
131
+ supersededBy?: string;
119
132
  };
120
133
  export declare const DELEGATION_LEDGER_SCHEMA_VERSION: 3;
121
134
  export type DelegationLedger = {
@@ -144,6 +157,98 @@ export declare function readDelegationEvents(projectRoot: string): Promise<{
144
157
  events: DelegationEvent[];
145
158
  corruptLines: number[];
146
159
  }>;
160
+ /**
161
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
162
+ * whose latest status is still active (`scheduled | launched |
163
+ * acknowledged`). Used by the `state/subagents.json` writer so the
164
+ * tracker never reports a span that already has a terminal row.
165
+ *
166
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
167
+ * consumers see a stable presentation order.
168
+ *
169
+ * Rows without a `spanId` are skipped — they are not addressable by
170
+ * the tracker contract and would collide on the empty key.
171
+ *
172
+ * Callers are expected to pass entries already filtered to the active
173
+ * `runId`; cross-run rows are therefore not re-filtered here.
174
+ *
175
+ * keep in sync with the inline copy in
176
+ * `src/content/hooks.ts::delegationRecordScript`.
177
+ */
178
+ export declare function computeActiveSubagents(entries: DelegationEntry[]): DelegationEntry[];
179
+ /**
180
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
181
+ * would push a span's timeline backwards. Carries enough context that
182
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
183
+ * JSON payload without re-deriving the offending field.
184
+ *
185
+ * keep in sync with the inline copy in
186
+ * `src/content/hooks.ts::delegationRecordScript`.
187
+ */
188
+ export declare class DelegationTimestampError extends Error {
189
+ readonly field: string;
190
+ readonly actual: string;
191
+ readonly priorBound: string;
192
+ constructor(field: string, actual: string, priorBound: string);
193
+ }
194
+ /**
195
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
196
+ * forward (or stay equal). Validates both per-row invariants
197
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
198
+ * invariant: the union of prior rows for this `spanId` plus the
199
+ * incoming row must have non-decreasing `ts`.
200
+ *
201
+ * Equality is allowed because fast-completing dispatches legitimately
202
+ * collapse multiple lifecycle markers onto the same instant.
203
+ *
204
+ * keep in sync with the inline copy in
205
+ * `src/content/hooks.ts::delegationRecordScript`.
206
+ */
207
+ export declare function validateMonotonicTimestamps(stamped: DelegationEntry, prior: DelegationEntry[]): void;
208
+ /**
209
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
210
+ * second `scheduled` span on the same `(stage, agent)` pair while an
211
+ * earlier span on the same pair is still active. Callers can catch and
212
+ * either pass the existing span id via `--supersede=<id>` (which
213
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
214
+ * concurrent spans intentionally.
215
+ */
216
+ export declare class DispatchDuplicateError extends Error {
217
+ readonly existingSpanId: string;
218
+ readonly existingStatus: DelegationStatus;
219
+ readonly newSpanId: string;
220
+ readonly pair: {
221
+ stage: string;
222
+ agent: string;
223
+ };
224
+ constructor(params: {
225
+ existingSpanId: string;
226
+ existingStatus: DelegationStatus;
227
+ newSpanId: string;
228
+ pair: {
229
+ stage: string;
230
+ agent: string;
231
+ };
232
+ });
233
+ }
234
+ /**
235
+ * v6.9.0 — find the latest active span for a given `(stage, agent)`
236
+ * pair in the supplied ledger entries. Returns the row whose latest
237
+ * status (after the latest-by-spanId fold) is still in the active set
238
+ * (`scheduled | launched | acknowledged`).
239
+ *
240
+ * Run-scope is **strict**: only entries whose `runId` matches the
241
+ * supplied `runId` are folded. Entries with empty/missing `runId`
242
+ * (legacy ledgers from v6.8 and earlier) are treated as NOT belonging
243
+ * to the current run, so they cannot keep an old span "active" across
244
+ * a fresh dispatch and trip a spurious `dispatch_duplicate`. This
245
+ * fixes R7: a slice-implementer that ran in run-1 must not block a
246
+ * slice-implementer scheduled in run-2.
247
+ *
248
+ * keep in sync with the inline copy in
249
+ * `src/content/hooks.ts::delegationRecordScript`.
250
+ */
251
+ export declare function findActiveSpanForPair(stage: string, agent: string, runId: string, ledger: DelegationLedger): DelegationEntry | null;
147
252
  export declare function appendDelegation(projectRoot: string, entry: DelegationEntry): Promise<void>;
148
253
  /**
149
254
  * Aggregate the fulfillment mode cclaw expects for the active harness set.
@@ -222,7 +222,9 @@ function isDelegationEntry(value) {
222
222
  retryOk &&
223
223
  (o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
224
224
  (o.skill === undefined || typeof o.skill === "string") &&
225
- (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3));
225
+ (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3) &&
226
+ (o.allowParallel === undefined || typeof o.allowParallel === "boolean") &&
227
+ (o.supersededBy === undefined || typeof o.supersededBy === "string"));
226
228
  }
227
229
  function isDelegationDispatchSurface(value) {
228
230
  return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
@@ -378,10 +380,205 @@ async function appendDelegationEvent(projectRoot, event) {
378
380
  await fs.mkdir(path.dirname(filePath), { recursive: true });
379
381
  await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
380
382
  }
383
+ /**
384
+ * Effective timestamp used to order rows that share a `spanId`. Newest
385
+ * lifecycle column wins. Returns the empty string when nothing is set
386
+ * so the caller still has a stable lexicographic compare key.
387
+ *
388
+ * keep in sync with the inline copy in
389
+ * `src/content/hooks.ts::delegationRecordScript`.
390
+ */
391
+ function effectiveSpanTs(entry) {
392
+ return entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? "";
393
+ }
394
+ const ACTIVE_DELEGATION_STATUSES = new Set([
395
+ "scheduled",
396
+ "launched",
397
+ "acknowledged"
398
+ ]);
399
+ /**
400
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
401
+ * whose latest status is still active (`scheduled | launched |
402
+ * acknowledged`). Used by the `state/subagents.json` writer so the
403
+ * tracker never reports a span that already has a terminal row.
404
+ *
405
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
406
+ * consumers see a stable presentation order.
407
+ *
408
+ * Rows without a `spanId` are skipped — they are not addressable by
409
+ * the tracker contract and would collide on the empty key.
410
+ *
411
+ * Callers are expected to pass entries already filtered to the active
412
+ * `runId`; cross-run rows are therefore not re-filtered here.
413
+ *
414
+ * keep in sync with the inline copy in
415
+ * `src/content/hooks.ts::delegationRecordScript`.
416
+ */
417
+ export function computeActiveSubagents(entries) {
418
+ const latestBySpan = new Map();
419
+ for (const entry of entries) {
420
+ if (!entry.spanId)
421
+ continue;
422
+ const existing = latestBySpan.get(entry.spanId);
423
+ if (!existing) {
424
+ latestBySpan.set(entry.spanId, entry);
425
+ continue;
426
+ }
427
+ const existingTs = effectiveSpanTs(existing);
428
+ const incomingTs = effectiveSpanTs(entry);
429
+ if (incomingTs >= existingTs) {
430
+ latestBySpan.set(entry.spanId, entry);
431
+ }
432
+ }
433
+ const folded = [];
434
+ for (const entry of latestBySpan.values()) {
435
+ if (ACTIVE_DELEGATION_STATUSES.has(entry.status)) {
436
+ folded.push(entry);
437
+ }
438
+ }
439
+ folded.sort((a, b) => {
440
+ const aKey = a.startTs ?? a.ts ?? "";
441
+ const bKey = b.startTs ?? b.ts ?? "";
442
+ if (aKey === bKey)
443
+ return 0;
444
+ return aKey < bKey ? -1 : 1;
445
+ });
446
+ return folded;
447
+ }
448
+ /**
449
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
450
+ * would push a span's timeline backwards. Carries enough context that
451
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
452
+ * JSON payload without re-deriving the offending field.
453
+ *
454
+ * keep in sync with the inline copy in
455
+ * `src/content/hooks.ts::delegationRecordScript`.
456
+ */
457
+ export class DelegationTimestampError extends Error {
458
+ field;
459
+ actual;
460
+ priorBound;
461
+ constructor(field, actual, priorBound) {
462
+ super(`delegation_timestamp_non_monotonic — ${field}: ${actual} < ${priorBound}`);
463
+ this.name = "DelegationTimestampError";
464
+ this.field = field;
465
+ this.actual = actual;
466
+ this.priorBound = priorBound;
467
+ }
468
+ }
469
+ /**
470
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
471
+ * forward (or stay equal). Validates both per-row invariants
472
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
473
+ * invariant: the union of prior rows for this `spanId` plus the
474
+ * incoming row must have non-decreasing `ts`.
475
+ *
476
+ * Equality is allowed because fast-completing dispatches legitimately
477
+ * collapse multiple lifecycle markers onto the same instant.
478
+ *
479
+ * keep in sync with the inline copy in
480
+ * `src/content/hooks.ts::delegationRecordScript`.
481
+ */
482
+ export function validateMonotonicTimestamps(stamped, prior) {
483
+ const startTs = stamped.startTs;
484
+ if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
485
+ throw new DelegationTimestampError("launchedTs", stamped.launchedTs, startTs);
486
+ }
487
+ if (stamped.ackTs) {
488
+ const ackBound = stamped.launchedTs ?? startTs;
489
+ if (ackBound && stamped.ackTs < ackBound) {
490
+ throw new DelegationTimestampError("ackTs", stamped.ackTs, ackBound);
491
+ }
492
+ }
493
+ if (stamped.completedTs) {
494
+ const completedBound = stamped.ackTs ?? stamped.launchedTs ?? startTs;
495
+ if (completedBound && stamped.completedTs < completedBound) {
496
+ throw new DelegationTimestampError("completedTs", stamped.completedTs, completedBound);
497
+ }
498
+ }
499
+ if (!stamped.spanId)
500
+ return;
501
+ const priorForSpan = prior.filter((entry) => entry.spanId === stamped.spanId);
502
+ if (priorForSpan.length === 0)
503
+ return;
504
+ const timeline = [...priorForSpan, stamped]
505
+ .map((entry) => ({ entry, ts: entry.ts ?? entry.startTs ?? "" }))
506
+ .filter((row) => row.ts.length > 0)
507
+ .sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? -1 : 1));
508
+ for (let i = 1; i < timeline.length; i += 1) {
509
+ const previous = timeline[i - 1];
510
+ const current = timeline[i];
511
+ if (current.ts < previous.ts) {
512
+ throw new DelegationTimestampError("ts", current.ts, previous.ts);
513
+ }
514
+ }
515
+ // Find the latest existing row by `ts` for the same spanId; if the
516
+ // new row's `ts` is older than that latest, the timeline regressed.
517
+ const latestPrior = priorForSpan
518
+ .map((entry) => entry.ts ?? entry.startTs ?? "")
519
+ .filter((ts) => ts.length > 0)
520
+ .sort()
521
+ .at(-1);
522
+ const stampedTs = stamped.ts ?? stamped.startTs ?? "";
523
+ if (latestPrior && stampedTs && stampedTs < latestPrior) {
524
+ throw new DelegationTimestampError("ts", stampedTs, latestPrior);
525
+ }
526
+ }
527
+ /**
528
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
529
+ * second `scheduled` span on the same `(stage, agent)` pair while an
530
+ * earlier span on the same pair is still active. Callers can catch and
531
+ * either pass the existing span id via `--supersede=<id>` (which
532
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
533
+ * concurrent spans intentionally.
534
+ */
535
+ export class DispatchDuplicateError extends Error {
536
+ existingSpanId;
537
+ existingStatus;
538
+ newSpanId;
539
+ pair;
540
+ constructor(params) {
541
+ super(`dispatch_duplicate — already-active spanId=${params.existingSpanId} (status=${params.existingStatus}) on stage=${params.pair.stage}, agent=${params.pair.agent}. ` +
542
+ `pass --supersede=${params.existingSpanId} to close the previous span as stale, or --allow-parallel to record both as concurrent.`);
543
+ this.name = "DispatchDuplicateError";
544
+ this.existingSpanId = params.existingSpanId;
545
+ this.existingStatus = params.existingStatus;
546
+ this.newSpanId = params.newSpanId;
547
+ this.pair = params.pair;
548
+ }
549
+ }
550
+ /**
551
+ * v6.9.0 — find the latest active span for a given `(stage, agent)`
552
+ * pair in the supplied ledger entries. Returns the row whose latest
553
+ * status (after the latest-by-spanId fold) is still in the active set
554
+ * (`scheduled | launched | acknowledged`).
555
+ *
556
+ * Run-scope is **strict**: only entries whose `runId` matches the
557
+ * supplied `runId` are folded. Entries with empty/missing `runId`
558
+ * (legacy ledgers from v6.8 and earlier) are treated as NOT belonging
559
+ * to the current run, so they cannot keep an old span "active" across
560
+ * a fresh dispatch and trip a spurious `dispatch_duplicate`. This
561
+ * fixes R7: a slice-implementer that ran in run-1 must not block a
562
+ * slice-implementer scheduled in run-2.
563
+ *
564
+ * keep in sync with the inline copy in
565
+ * `src/content/hooks.ts::delegationRecordScript`.
566
+ */
567
+ export function findActiveSpanForPair(stage, agent, runId, ledger) {
568
+ const sameRun = ledger.entries.filter((entry) => {
569
+ if (typeof entry.runId !== "string" || entry.runId.length === 0)
570
+ return false;
571
+ if (entry.runId !== runId)
572
+ return false;
573
+ return entry.stage === stage && entry.agent === agent;
574
+ });
575
+ for (const entry of computeActiveSubagents(sameRun)) {
576
+ return entry;
577
+ }
578
+ return null;
579
+ }
381
580
  async function writeSubagentTracker(projectRoot, entries) {
382
- const active = entries
383
- .filter((entry) => entry.status === "scheduled" || entry.status === "launched" || entry.status === "acknowledged")
384
- .map((entry) => ({
581
+ const active = computeActiveSubagents(entries).map((entry) => ({
385
582
  spanId: entry.spanId,
386
583
  dispatchId: entry.dispatchId,
387
584
  workerRunId: entry.workerRunId,
@@ -392,7 +589,8 @@ async function writeSubagentTracker(projectRoot, entries) {
392
589
  agentDefinitionPath: entry.agentDefinitionPath,
393
590
  startedAt: entry.startTs,
394
591
  launchedAt: entry.launchedTs,
395
- acknowledgedAt: entry.ackTs
592
+ acknowledgedAt: entry.ackTs,
593
+ allowParallel: entry.allowParallel
396
594
  }));
397
595
  await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
398
596
  }
@@ -401,7 +599,20 @@ export async function appendDelegation(projectRoot, entry) {
401
599
  await withDirectoryLock(delegationLockPath(projectRoot), async () => {
402
600
  const filePath = delegationLogPath(projectRoot);
403
601
  const prior = await readDelegationLedger(projectRoot);
404
- const startTs = entry.startTs ?? entry.ts ?? new Date().toISOString();
602
+ // Span start anchor: prefer explicit `startTs`; otherwise fall back to
603
+ // the earliest provided lifecycle marker so the monotonic validator
604
+ // never sees a synthetic `now` overshoot a real event timestamp.
605
+ const lifecycleCandidates = [
606
+ entry.startTs,
607
+ entry.launchedTs,
608
+ entry.ackTs,
609
+ entry.completedTs,
610
+ entry.ts
611
+ ].filter((value) => typeof value === "string" && value.length > 0);
612
+ const earliestLifecycle = lifecycleCandidates.length > 0
613
+ ? lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min))
614
+ : undefined;
615
+ const startTs = entry.startTs ?? earliestLifecycle ?? new Date().toISOString();
405
616
  if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
406
617
  throw new Error("waived delegation entries require a non-empty waiverReason");
407
618
  }
@@ -445,6 +656,18 @@ export async function appendDelegation(projectRoot, entry) {
445
656
  if (prior.entries.some((existing) => existing.spanId === stamped.spanId && existing.status === stamped.status)) {
446
657
  return;
447
658
  }
659
+ validateMonotonicTimestamps(stamped, prior.entries);
660
+ if (stamped.status === "scheduled" && stamped.allowParallel !== true) {
661
+ const existing = findActiveSpanForPair(stamped.stage, stamped.agent, activeRunId, prior);
662
+ if (existing && existing.spanId && existing.spanId !== stamped.spanId) {
663
+ throw new DispatchDuplicateError({
664
+ existingSpanId: existing.spanId,
665
+ existingStatus: existing.status,
666
+ newSpanId: stamped.spanId,
667
+ pair: { stage: stamped.stage, agent: stamped.agent }
668
+ });
669
+ }
670
+ }
448
671
  await appendDelegationEvent(projectRoot, eventFromEntry(stamped));
449
672
  const ledger = {
450
673
  runId: activeRunId,
@@ -137,9 +137,23 @@ export function parseEarlyLoopLog(text, options = {}) {
137
137
  continue;
138
138
  }
139
139
  }
140
+ // v6.9.0 schema repair: legacy logs may carry rows with no runId
141
+ // (the prior parser silently coerced them to "active", which then
142
+ // collided across runs). Surface a structured warning on read but
143
+ // skip the row so derived status doesn't fold cross-run state.
144
+ // Writers must always provide a runId (enforced upstream in the
145
+ // CLI/hook surface).
146
+ if (runId.length === 0) {
147
+ issues?.push({
148
+ lineNumber,
149
+ reason: "missing-runId: legacy entry skipped to avoid cross-run pollution",
150
+ rawLine: raw
151
+ });
152
+ continue;
153
+ }
140
154
  entries.push({
141
155
  ts: normalizeText(parsed.ts, ""),
142
- runId: runId.length > 0 ? runId : "active",
156
+ runId,
143
157
  stage: stage.length > 0 ? stage : "brainstorm",
144
158
  iteration,
145
159
  concerns,
@@ -9,8 +9,8 @@ import { readDelegationLedger } from "./delegation.js";
9
9
  import { exists } from "./fs-utils.js";
10
10
  import { computeEarlyLoopStatus, isEarlyLoopStage, normalizeEarlyLoopMaxIterations } from "./early-loop.js";
11
11
  import { detectPublicApiChanges } from "./internal/detect-public-api-changes.js";
12
+ import { detectSupplyChainChanges } from "./internal/detect-supply-chain-changes.js";
12
13
  import { readFlowState, writeFlowState } from "./runs.js";
13
- import { parseTddCycleLog, validateTddCycleOrder } from "./tdd-cycle.js";
14
14
  import { validateTddVerificationEvidence } from "./tdd-verification-evidence.js";
15
15
  async function currentStageArtifactExists(projectRoot, stage, track) {
16
16
  const resolved = await resolveArtifactPath(stage, {
@@ -376,35 +376,20 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState, opt
376
376
  }
377
377
  if (stage === "tdd") {
378
378
  const docsDriftDetection = await detectPublicApiChanges(projectRoot);
379
- if (docsDriftDetection.triggered) {
379
+ const supplyChainDetection = await detectSupplyChainChanges(projectRoot);
380
+ if (docsDriftDetection.triggered || supplyChainDetection.triggered) {
380
381
  const ledger = await readDelegationLedger(projectRoot);
381
382
  const hasDocUpdaterCompletion = ledger.entries.some((entry) => entry.runId === flowState.activeRunId &&
382
383
  entry.stage === "tdd" &&
383
384
  entry.agent === "doc-updater" &&
384
385
  entry.status === "completed");
385
386
  if (!hasDocUpdaterCompletion) {
386
- issues.push(`tdd docs drift gate blocked (tdd_docs_drift_check): public surface changes detected (${docsDriftDetection.changedFiles.join(", ")}) but no completed doc-updater delegation exists for the active run.`);
387
- }
388
- }
389
- const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
390
- if (await exists(tddLogPath)) {
391
- try {
392
- const tddLogRaw = await fs.readFile(tddLogPath, "utf8");
393
- const parsedCycles = parseTddCycleLog(tddLogRaw);
394
- const tddOrderValidation = validateTddCycleOrder(parsedCycles, {
395
- runId: flowState.activeRunId
396
- });
397
- if (!tddOrderValidation.ok) {
398
- const details = [...tddOrderValidation.issues];
399
- if (tddOrderValidation.openRedSlices.length > 0) {
400
- details.push(`open red slices: ${tddOrderValidation.openRedSlices.join(", ")}`);
401
- }
402
- issues.push(`tdd cycle order gate blocked: ${details.join("; ")}`);
387
+ if (docsDriftDetection.triggered) {
388
+ issues.push(`tdd docs drift gate blocked (tdd_docs_drift_check): public surface changes detected (${docsDriftDetection.changedFiles.join(", ")}) but no completed doc-updater delegation exists for the active run.`);
389
+ }
390
+ if (supplyChainDetection.triggered) {
391
+ issues.push(`tdd docs drift gate blocked (tdd_docs_drift_check): supply-chain changes detected (${supplyChainDetection.changedFiles.join(", ")}) but no completed doc-updater delegation exists for the active run.`);
403
392
  }
404
- }
405
- catch (err) {
406
- const reason = err instanceof Error ? err.message : String(err);
407
- issues.push(`tdd cycle order gate blocked: unable to read tdd-cycle-log.jsonl (${reason}).`);
408
393
  }
409
394
  }
410
395
  }
@@ -477,6 +462,13 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState, opt
477
462
  forcingPending: floor.forcingPending,
478
463
  noNewDecisions: floor.noNewDecisions
479
464
  };
465
+ // v6.9.0 — when the QA log floor is blocking, mirror that decision into
466
+ // `gates.issues` so the harness has a single structured source of truth
467
+ // for "this stage is blocked". The `qa_log_unconverged` linter rule
468
+ // remains the verbose detail/fallback channel.
469
+ if (qaLogFloor.blocking) {
470
+ issues.push(`qa log floor blocked (qa_log_unconverged): ${qaLogFloor.count}/${qaLogFloor.min} entries on stage "${stage}" (track=${flowState.track}, discoveryMode=${flowState.discoveryMode ?? "default"}). Continue elicitation or pass --skip-questions to record the stop.`);
471
+ }
480
472
  }
481
473
  return {
482
474
  ok: issues.length === 0,
@@ -293,9 +293,11 @@ export function harnessesByTier() {
293
293
  });
294
294
  }
295
295
  function ironLawsAgentsMdBlock() {
296
+ // v6.9.0: keep this set in sync with `ironLawsSkillMarkdown()` —
297
+ // post-Phase A, only `stop-clean-or-handoff` is still hook-enforced
298
+ // (Stop hook). All other iron laws live in stage HARD-GATE blocks.
296
299
  const enforcedLawIds = new Set([
297
- "stop-clean-or-handoff",
298
- "review-coverage-complete-before-ship"
300
+ "stop-clean-or-handoff"
299
301
  ]);
300
302
  const enforcedRows = IRON_LAWS
301
303
  .filter((law) => enforcedLawIds.has(law.id))