cclaw-cli 0.46.15 → 0.48.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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/dist/artifact-linter.d.ts +7 -0
  3. package/dist/artifact-linter.js +169 -8
  4. package/dist/config.d.ts +6 -6
  5. package/dist/config.js +22 -0
  6. package/dist/constants.d.ts +10 -1
  7. package/dist/constants.js +19 -10
  8. package/dist/content/contracts.d.ts +1 -1
  9. package/dist/content/contracts.js +1 -1
  10. package/dist/content/{harnesses-doc.js → harness-doc.js} +32 -1
  11. package/dist/content/harness-playbooks.js +4 -4
  12. package/dist/content/ideate-command.js +19 -19
  13. package/dist/content/skills.js +2 -2
  14. package/dist/content/stage-schema.js +54 -15
  15. package/dist/content/stages/design.js +2 -2
  16. package/dist/content/stages/review.js +1 -1
  17. package/dist/content/stages/ship.js +2 -0
  18. package/dist/content/stages/tdd.js +8 -4
  19. package/dist/content/templates.js +4 -3
  20. package/dist/delegation.js +107 -26
  21. package/dist/doctor.js +77 -9
  22. package/dist/flow-state.d.ts +8 -0
  23. package/dist/flow-state.js +11 -8
  24. package/dist/gate-evidence.js +26 -2
  25. package/dist/harness-adapters.d.ts +2 -2
  26. package/dist/harness-adapters.js +2 -2
  27. package/dist/install.js +28 -6
  28. package/dist/internal/advance-stage.js +53 -16
  29. package/dist/internal/detect-public-api-changes.d.ts +5 -0
  30. package/dist/internal/detect-public-api-changes.js +45 -0
  31. package/dist/policy.js +3 -2
  32. package/dist/retro-gate.js +30 -3
  33. package/dist/run-persistence.js +16 -5
  34. package/dist/tdd-cycle.js +19 -1
  35. package/dist/types.d.ts +6 -1
  36. package/package.json +4 -1
  37. /package/dist/content/{harnesses-doc.d.ts → harness-doc.d.ts} +0 -0
@@ -1,6 +1,17 @@
1
- import { COMMAND_FILE_ORDER } from "../constants.js";
1
+ import { FLOW_STAGES, FLOW_TRACKS, TRACK_STAGES } from "../types.js";
2
+ import { STAGE_TO_SKILL_FOLDER } from "../constants.js";
2
3
  import { BRAINSTORM, SCOPE, DESIGN, SPEC, PLAN, TDD, REVIEW, SHIP } from "./stages/index.js";
3
4
  import { tddStageForTrack } from "./stages/tdd.js";
5
+ const ARTIFACT_STAGE_BY_PATH = {
6
+ ".cclaw/artifacts/01-brainstorm.md": "brainstorm",
7
+ ".cclaw/artifacts/02-scope.md": "scope",
8
+ ".cclaw/artifacts/03-design.md": "design",
9
+ ".cclaw/artifacts/04-spec.md": "spec",
10
+ ".cclaw/artifacts/05-plan.md": "plan",
11
+ ".cclaw/artifacts/06-tdd.md": "tdd",
12
+ ".cclaw/artifacts/07-review.md": "review",
13
+ ".cclaw/artifacts/08-ship.md": "ship"
14
+ };
4
15
  const REQUIRED_GATE_IDS = {
5
16
  brainstorm: [
6
17
  "brainstorm_approaches_compared",
@@ -91,6 +102,16 @@ function tieredArtifactValidation(stage, rows) {
91
102
  };
92
103
  });
93
104
  }
105
+ function readsFromForTrack(readsFrom, track) {
106
+ const stageSet = new Set(TRACK_STAGES[track]);
107
+ return readsFrom.filter((artifactPath) => {
108
+ const stage = ARTIFACT_STAGE_BY_PATH[artifactPath];
109
+ if (!stage) {
110
+ return true;
111
+ }
112
+ return stageSet.has(stage);
113
+ });
114
+ }
94
115
  // ---------------------------------------------------------------------------
95
116
  // Stage map and accessors
96
117
  // ---------------------------------------------------------------------------
@@ -198,8 +219,8 @@ const STAGE_AUTO_SUBAGENT_DISPATCH = {
198
219
  },
199
220
  {
200
221
  agent: "reviewer",
201
- mode: "proactive",
202
- when: "When the diff exceeds 100 changed lines, touches more than 10 files, or modifies trust boundaries — dispatch a SECOND, independent reviewer with the adversarial-review skill loaded so the review army has at least two voices on a high-blast-radius change.",
222
+ mode: "mandatory",
223
+ when: "Mandatory when the diff exceeds 100 changed lines, touches more than 10 files, or modifies trust boundaries — dispatch a SECOND, independent reviewer with the adversarial-review skill loaded so the review army has at least two voices on a high-blast-radius change.",
203
224
  purpose: "Adversarial second-opinion review on large or trust-sensitive diffs. The second reviewer treats the implementation as hostile and tries to break it (hostile-user, future-maintainer, competitor lenses) instead of sympathetically explaining it.",
204
225
  requiresUserGate: false,
205
226
  skill: "adversarial-review"
@@ -232,23 +253,29 @@ const STAGE_AUTO_SUBAGENT_DISPATCH = {
232
253
  };
233
254
  /** Transition guard: agents with `mode: "mandatory"` in auto-subagent dispatch for this stage. */
234
255
  export function mandatoryDelegationsForStage(stage) {
235
- return STAGE_AUTO_SUBAGENT_DISPATCH[stage]
236
- .filter((d) => d.mode === "mandatory")
237
- .map((d) => d.agent);
256
+ return [...new Set(STAGE_AUTO_SUBAGENT_DISPATCH[stage]
257
+ .filter((d) => d.mode === "mandatory")
258
+ .map((d) => d.agent))];
238
259
  }
239
260
  export function stageSchema(stage, track = "standard") {
240
261
  const base = stage === "tdd" ? tddStageForTrack(track) : STAGE_SCHEMA_MAP[stage];
241
262
  const tieredGates = tieredStageGates(stage, base.requiredGates, track);
242
263
  const tieredValidation = tieredArtifactValidation(stage, base.artifactValidation);
264
+ const crossStageTrace = {
265
+ ...base.crossStageTrace,
266
+ readsFrom: readsFromForTrack(base.crossStageTrace.readsFrom, track)
267
+ };
243
268
  return {
244
269
  ...base,
270
+ skillFolder: STAGE_TO_SKILL_FOLDER[stage],
271
+ crossStageTrace,
245
272
  requiredGates: tieredGates,
246
273
  artifactValidation: tieredValidation,
247
274
  mandatoryDelegations: mandatoryDelegationsForStage(stage)
248
275
  };
249
276
  }
250
277
  export function orderedStageSchemas(track = "standard") {
251
- return COMMAND_FILE_ORDER.map((stage) => stageSchema(stage, track));
278
+ return FLOW_STAGES.map((stage) => stageSchema(stage, track));
252
279
  }
253
280
  export function stageGateIds(stage, track = "standard") {
254
281
  return stageSchema(stage, track).requiredGates
@@ -266,15 +293,27 @@ export function nextCclawCommand(stage) {
266
293
  }
267
294
  export function buildTransitionRules() {
268
295
  const rules = [];
269
- for (const schema of orderedStageSchemas()) {
270
- if (schema.next === "done") {
271
- continue;
296
+ const seen = new Set();
297
+ // Derive transitions from every track so medium/quick (which skip stages)
298
+ // get their neighbour edges registered alongside the standard chain.
299
+ // Previously only the standard track produced rules, so `canTransition`
300
+ // returned false for legitimate medium/quick transitions (e.g. brainstorm
301
+ // -> spec on medium) even though `nextStage` correctly advanced them.
302
+ for (const track of FLOW_TRACKS) {
303
+ const ordered = TRACK_STAGES[track];
304
+ for (let i = 0; i < ordered.length - 1; i += 1) {
305
+ const from = ordered[i];
306
+ const to = ordered[i + 1];
307
+ const key = `${from}->${to}`;
308
+ if (seen.has(key))
309
+ continue;
310
+ seen.add(key);
311
+ rules.push({
312
+ from,
313
+ to,
314
+ guards: stageGateIds(from, track)
315
+ });
272
316
  }
273
- rules.push({
274
- from: schema.stage,
275
- to: schema.next,
276
- guards: stageGateIds(schema.stage)
277
- });
278
317
  }
279
318
  // Review can explicitly route back to TDD when the verdict is BLOCKED.
280
319
  rules.push({
@@ -10,7 +10,7 @@ export const DESIGN = {
10
10
  ironLaw: "NO DESIGN DECISION WITHOUT A LABELED DIAGRAM, A REJECTED ALTERNATIVE, AND A NAMED FAILURE MODE.",
11
11
  purpose: "Lock architecture, data flow, failure modes, and test/performance expectations through rigorous interactive review.",
12
12
  whenToUse: [
13
- "After scope contract approval",
13
+ "After scope agreement approval",
14
14
  "Before writing final spec and execution plan",
15
15
  "When architecture risks need explicit treatment"
16
16
  ],
@@ -79,7 +79,7 @@ export const DESIGN = {
79
79
  "What-already-exists section produced.",
80
80
  "Completion dashboard lists every review section status, decision count, and unresolved items (or 'None')."
81
81
  ],
82
- inputs: ["scope contract", "system constraints", "non-functional requirements"],
82
+ inputs: ["scope agreement artifact", "system constraints", "non-functional requirements"],
83
83
  requiredContext: [
84
84
  "parallel research synthesis from `.cclaw/artifacts/02a-research.md`",
85
85
  "existing architecture and boundaries",
@@ -201,7 +201,7 @@ export const REVIEW = {
201
201
  },
202
202
  artifactValidation: [
203
203
  { section: "Layer 1 Verdict", required: true, validationRule: "Per-criterion pass/fail with references." },
204
- { section: "Layer 2 Findings", required: false, validationRule: "Each finding has severity, description, and resolution status." },
204
+ { section: "Layer 2 Findings", required: false, validationRule: "Each finding has severity, description, and resolution status. Security coverage must include either explicit security findings or `NO_CHANGE_ATTESTATION: <reason>` when no security-relevant changes were found." },
205
205
  { section: "Review Army Contract", required: true, validationRule: "Structured findings include id/severity/confidence/fingerprint/reportedBy/status with dedup reconciliation summary." },
206
206
  { section: "Review Readiness Dashboard", required: false, validationRule: "Includes a per-pass table (Layer 1 / Layer 2 / Adversarial / Schema) with a 'Completed at' column, a Delegation log snapshot block (path .cclaw/state/delegation-log.json with required/completed/waived/pending), a Staleness signal block (commit at last review pass and current commit), and a Headline with open critical blockers + ship recommendation. At minimum, the section text must contain the substrings 'Completed at', 'delegation-log.json', 'commit at last review pass', and 'Ship recommendation'." },
207
207
  { section: "Completeness Score", required: false, validationRule: "Records AC coverage, task coverage, test-slice coverage, and adversarial-review pass status as numeric or boolean values. At minimum, a line like 'AC coverage: N/M' or 'AC coverage: 100%'." },
@@ -100,6 +100,8 @@ export const SHIP = {
100
100
  "FINALIZE_NO_VCS"
101
101
  ],
102
102
  artifactFile: "08-ship.md",
103
+ // `done` exits the stage pipeline. Archive semantics are handled by the
104
+ // closeout substate machine (`idle` -> ... -> `archived`) in flow-state.
103
105
  next: "done",
104
106
  reviewSections: [
105
107
  {
@@ -63,7 +63,8 @@ export const TDD = {
63
63
  { id: "tdd_green_full_suite", description: "Full relevant suite passes in GREEN state." },
64
64
  { id: "tdd_refactor_completed", description: "Refactor pass completed with behavior preservation verified." },
65
65
  { id: "tdd_verified_before_complete", description: "Fresh verification evidence includes test command, commit SHA, and explicit pass/fail status." },
66
- { id: "tdd_traceable_to_plan", description: "Change traceability to plan slice is explicit." }
66
+ { id: "tdd_traceable_to_plan", description: "Change traceability to plan slice is explicit." },
67
+ { id: "tdd_docs_drift_check", description: "When public API/config/CLI surfaces change, docs drift is addressed via a completed doc-updater pass." }
67
68
  ],
68
69
  requiredEvidence: [
69
70
  "Artifact updated at `.cclaw/artifacts/06-tdd.md` with RED, GREEN, and REFACTOR sections.",
@@ -206,9 +207,12 @@ function tddQuickTrackVariant() {
206
207
  checklist: TDD.checklist.map(quickTrackText),
207
208
  interactionProtocol: TDD.interactionProtocol.map(quickTrackText),
208
209
  process: TDD.process.map(quickTrackText),
209
- requiredGates: TDD.requiredGates.map((gate) => gate.id === "tdd_traceable_to_plan"
210
- ? { ...gate, description: "Change traceability to acceptance criterion is explicit." }
211
- : gate),
210
+ requiredGates: TDD.requiredGates
211
+ .filter((gate) => gate.id !== "tdd_traceable_to_plan")
212
+ .map((gate) => ({
213
+ ...gate,
214
+ description: quickTrackText(gate.description)
215
+ })),
212
216
  requiredEvidence: TDD.requiredEvidence.map(quickTrackText),
213
217
  inputs: TDD.inputs.map(quickTrackText),
214
218
  requiredContext: ["spec artifact", "existing test patterns"],
@@ -1,5 +1,5 @@
1
- import { COMMAND_FILE_ORDER } from "../constants.js";
2
1
  import { orderedStageSchemas } from "./stage-schema.js";
2
+ import { FLOW_STAGES } from "../types.js";
3
3
  export const ARTIFACT_TEMPLATES = {
4
4
  "01-brainstorm.md": `---
5
5
  stage: brainstorm
@@ -522,6 +522,7 @@ inputs_hash: sha256:pending
522
522
  | ID | Severity | Category | Description | Status |
523
523
  |---|---|---|---|---|
524
524
  | R-1 | Critical/Important/Suggestion | correctness/security/performance/architecture | | open/resolved |
525
+ - NO_CHANGE_ATTESTATION: <required when Category=security has no entries; explain why no security-relevant changes were detected>
525
526
 
526
527
  ## Incoming Feedback Queue
527
528
  | ID | Source | Severity | File:line | Request | Status | Evidence |
@@ -802,7 +803,7 @@ Track-specific skips are allowed only when \`flow-state.track\` + \`skippedStage
802
803
  export function buildRulesJson() {
803
804
  return {
804
805
  version: 1,
805
- stage_order: COMMAND_FILE_ORDER,
806
+ stage_order: FLOW_STAGES,
806
807
  stage_gates: Object.fromEntries(orderedStageSchemas().map((schema) => [
807
808
  schema.stage,
808
809
  schema.requiredGates.map((gate) => gate.id)
@@ -820,7 +821,7 @@ export function buildRulesJson() {
820
821
  "conventional_commits"
821
822
  ],
822
823
  MUST_NEVER: [
823
- "skip_test_stage",
824
+ "skip_tdd_stage",
824
825
  "ship_with_critical_findings",
825
826
  "implement_in_brainstorm",
826
827
  "manual_edit_generated",
@@ -1,11 +1,14 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
3
5
  import { RUNTIME_ROOT } from "./constants.js";
4
6
  import { readConfig } from "./config.js";
5
7
  import { exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
6
8
  import { HARNESS_ADAPTERS } from "./harness-adapters.js";
7
9
  import { readFlowState } from "./runs.js";
8
10
  import { stageSchema } from "./content/stage-schema.js";
11
+ const execFileAsync = promisify(execFile);
9
12
  function delegationLogPath(projectRoot) {
10
13
  return path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-log.json");
11
14
  }
@@ -15,6 +18,82 @@ function delegationLockPath(projectRoot) {
15
18
  function createSpanId() {
16
19
  return `dspan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
17
20
  }
21
+ async function resolveReviewDiffBase(projectRoot) {
22
+ let head = "";
23
+ try {
24
+ head = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot })).stdout.trim();
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ const candidates = ["origin/main", "origin/master", "main", "master"];
30
+ for (const candidate of candidates) {
31
+ try {
32
+ await execFileAsync("git", ["rev-parse", "--verify", candidate], { cwd: projectRoot });
33
+ const { stdout } = await execFileAsync("git", ["merge-base", "HEAD", candidate], {
34
+ cwd: projectRoot
35
+ });
36
+ const base = stdout.trim();
37
+ if (base.length > 0 && base !== head) {
38
+ return base;
39
+ }
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ }
45
+ try {
46
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD~1"], {
47
+ cwd: projectRoot
48
+ });
49
+ const base = stdout.trim();
50
+ return base.length > 0 ? base : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ async function detectReviewTriggers(projectRoot) {
57
+ const empty = {
58
+ changedFiles: 0,
59
+ changedLines: 0,
60
+ trustBoundaryChanged: false,
61
+ requireAdversarialReviewer: false
62
+ };
63
+ const base = await resolveReviewDiffBase(projectRoot);
64
+ if (!base) {
65
+ return empty;
66
+ }
67
+ try {
68
+ const range = `${base}..HEAD`;
69
+ const shortstat = await execFileAsync("git", ["diff", "--shortstat", range], {
70
+ cwd: projectRoot
71
+ });
72
+ const short = shortstat.stdout.trim();
73
+ const changedFiles = Number((/(\d+)\s+files?\s+changed/u.exec(short)?.[1] ?? "0"));
74
+ const insertions = Number((/(\d+)\s+insertions?\(\+\)/u.exec(short)?.[1] ?? "0"));
75
+ const deletions = Number((/(\d+)\s+deletions?\(-\)/u.exec(short)?.[1] ?? "0"));
76
+ const changedLines = insertions + deletions;
77
+ const names = await execFileAsync("git", ["diff", "--name-only", range], {
78
+ cwd: projectRoot
79
+ });
80
+ const changedPaths = names.stdout
81
+ .split(/\r?\n/gu)
82
+ .map((line) => line.trim())
83
+ .filter((line) => line.length > 0);
84
+ const trustBoundaryChanged = changedPaths.some((filePath) => /(auth|security|secret|token|credential|permission|acl|policy|oauth|session|encrypt|decrypt|input|validation)/iu.test(filePath));
85
+ const requireAdversarialReviewer = changedLines > 100 || changedFiles > 10 || trustBoundaryChanged;
86
+ return {
87
+ changedFiles,
88
+ changedLines,
89
+ trustBoundaryChanged,
90
+ requireAdversarialReviewer
91
+ };
92
+ }
93
+ catch {
94
+ return empty;
95
+ }
96
+ }
18
97
  function isDelegationTokenUsage(value) {
19
98
  if (!value || typeof value !== "object" || Array.isArray(value))
20
99
  return false;
@@ -76,6 +155,8 @@ function parseLedger(raw, runId) {
76
155
  for (const item of entriesRaw) {
77
156
  if (isDelegationEntry(item)) {
78
157
  const ts = item.startTs ?? item.ts ?? new Date().toISOString();
158
+ const inferredFulfillmentMode = item.fulfillmentMode
159
+ ?? (item.status === "completed" ? "isolated" : undefined);
79
160
  entries.push({
80
161
  ...item,
81
162
  spanId: item.spanId ?? createSpanId(),
@@ -85,6 +166,7 @@ function parseLedger(raw, runId) {
85
166
  ? item.retryCount
86
167
  : 0,
87
168
  evidenceRefs: Array.isArray(item.evidenceRefs) ? item.evidenceRefs : [],
169
+ fulfillmentMode: inferredFulfillmentMode,
88
170
  schemaVersion: 1
89
171
  });
90
172
  }
@@ -126,6 +208,19 @@ export async function appendDelegation(projectRoot, entry) {
126
208
  if (!Array.isArray(stamped.evidenceRefs)) {
127
209
  stamped.evidenceRefs = [];
128
210
  }
211
+ if (stamped.status === "completed" && stamped.fulfillmentMode === undefined) {
212
+ const config = await readConfig(projectRoot).catch(() => null);
213
+ const harnesses = config?.harnesses ?? [];
214
+ const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
215
+ stamped.fulfillmentMode = expectedFulfillmentMode(fallbacks);
216
+ }
217
+ // Idempotency: if a caller (or a retried hook) tries to append a row
218
+ // with a spanId that already exists in the ledger, treat it as a no-op
219
+ // instead of growing the log with duplicate entries that subsequent
220
+ // delegation checks would mis-count.
221
+ if (prior.entries.some((existing) => existing.spanId === stamped.spanId)) {
222
+ return;
223
+ }
129
224
  const ledger = {
130
225
  runId: activeRunId,
131
226
  entries: [...prior.entries, stamped]
@@ -167,45 +262,31 @@ export async function checkMandatoryDelegations(projectRoot, stage) {
167
262
  const harnesses = config?.harnesses ?? [];
168
263
  const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
169
264
  const expectedMode = expectedFulfillmentMode(fallbacks);
170
- const onlyWaiverFallback = harnesses.length > 0 && fallbacks.every((f) => f === "waiver");
265
+ const reviewTriggers = stage === "review" ? await detectReviewTriggers(projectRoot) : null;
171
266
  for (const agent of mandatory) {
172
267
  const rows = forRun.filter((e) => e.agent === agent);
173
268
  const completedRows = rows.filter((e) => e.status === "completed");
174
269
  const waivedRows = rows.filter((e) => e.status === "waived");
175
- const hasCompleted = completedRows.length > 0;
270
+ const requiredCompletedCount = stage === "review" &&
271
+ agent === "reviewer" &&
272
+ reviewTriggers?.requireAdversarialReviewer
273
+ ? 2
274
+ : 1;
275
+ const hasCompleted = completedRows.length >= requiredCompletedCount;
176
276
  const hasWaived = waivedRows.length > 0;
177
277
  const ok = hasCompleted || hasWaived;
178
278
  if (!ok) {
179
- if (onlyWaiverFallback) {
180
- const existingHarnessWaiver = rows.some((e) => e.status === "waived" && e.waiverReason === "harness_limitation");
181
- if (!existingHarnessWaiver) {
182
- await appendDelegation(projectRoot, {
183
- stage,
184
- agent,
185
- mode: "mandatory",
186
- status: "waived",
187
- waiverReason: "harness_limitation",
188
- fulfillmentMode: "harness-waiver",
189
- ts: new Date().toISOString(),
190
- runId: activeRunId
191
- });
192
- }
193
- waived.push(agent);
194
- autoWaived.push(agent);
195
- }
196
- else {
197
- missing.push(agent);
198
- }
279
+ missing.push(agent);
199
280
  continue;
200
281
  }
201
282
  if (hasWaived) {
202
283
  waived.push(agent);
203
284
  }
204
- // Under role-switch fallback, a `completed` row is only credible if it
205
- // carries at least one evidenceRef otherwise the agent might have
206
- // claimed role-switch satisfaction without showing its work.
285
+ // Evidence is required for any non-isolated completion mode. Legacy rows
286
+ // without fulfillmentMode are inferred to `isolated` during parse.
287
+ const evidenceRequired = completedRows.some((e) => (e.fulfillmentMode ?? "isolated") !== "isolated");
207
288
  if (hasCompleted &&
208
- expectedMode === "role-switch" &&
289
+ evidenceRequired &&
209
290
  !completedRows.some((e) => Array.isArray(e.evidenceRefs) && e.evidenceRefs.length > 0)) {
210
291
  missingEvidence.push(agent);
211
292
  }
package/dist/doctor.js CHANGED
@@ -3,16 +3,16 @@ import path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { promisify } from "node:util";
6
- import { COMMAND_FILE_ORDER, REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
6
+ import { REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
7
7
  import { CCLAW_AGENTS } from "./content/core-agents.js";
8
- import { readConfig } from "./config.js";
8
+ import { detectAdvancedKeys, readConfig } from "./config.js";
9
9
  import { exists } from "./fs-utils.js";
10
10
  import { gitignoreHasRequiredPatterns } from "./gitignore.js";
11
11
  import { HARNESS_ADAPTERS, CCLAW_MARKER_START, CCLAW_MARKER_END, harnessShimFileNames, harnessShimSkillNames } from "./harness-adapters.js";
12
12
  import { policyChecks } from "./policy.js";
13
13
  import { readFlowState } from "./runs.js";
14
14
  import { skippedStagesForTrack } from "./flow-state.js";
15
- import { TRACK_STAGES } from "./types.js";
15
+ import { FLOW_STAGES, TRACK_STAGES } from "./types.js";
16
16
  import { checkMandatoryDelegations } from "./delegation.js";
17
17
  import { ensureFeatureSystem, listFeatures, readActiveFeature, readFeatureWorktreeRegistry, resolveFeatureWorkspacePath, worktreeRegistryPath } from "./feature-system.js";
18
18
  import { buildTraceMatrix } from "./trace-matrix.js";
@@ -280,7 +280,7 @@ export async function doctorChecks(projectRoot, options = {}) {
280
280
  details: fullPath
281
281
  });
282
282
  }
283
- for (const stage of COMMAND_FILE_ORDER) {
283
+ for (const stage of FLOW_STAGES) {
284
284
  const commandPath = path.join(projectRoot, RUNTIME_ROOT, "commands", `${stage}.md`);
285
285
  checks.push({
286
286
  name: `command:${stage}`,
@@ -377,7 +377,7 @@ export async function doctorChecks(projectRoot, options = {}) {
377
377
  // skill's Examples section points here; the file MUST exist or the pointer
378
378
  // is a dangling link.
379
379
  const stageRefDir = path.join(projectRoot, RUNTIME_ROOT, "references", "stages");
380
- for (const stage of COMMAND_FILE_ORDER) {
380
+ for (const stage of FLOW_STAGES) {
381
381
  const refPath = path.join(stageRefDir, `${stage}-examples.md`);
382
382
  checks.push({
383
383
  name: `stage_examples_ref:${stage}`,
@@ -430,6 +430,18 @@ export async function doctorChecks(projectRoot, options = {}) {
430
430
  });
431
431
  }
432
432
  if (parsedConfig) {
433
+ const advancedKeys = await detectAdvancedKeys(projectRoot).catch(() => new Set());
434
+ const hasLegacyTddTestGlobs = advancedKeys.has("tddTestGlobs");
435
+ const hasModernTddConfig = advancedKeys.has("tdd");
436
+ checks.push({
437
+ name: "warning:config:deprecated_tdd_test_globs",
438
+ ok: !hasLegacyTddTestGlobs,
439
+ details: hasLegacyTddTestGlobs
440
+ ? hasModernTddConfig
441
+ ? `warning: ${RUNTIME_ROOT}/config.yaml sets deprecated "tddTestGlobs" alongside "tdd.*"; "tdd.testPathPatterns" takes precedence. Remove legacy key.`
442
+ : `warning: ${RUNTIME_ROOT}/config.yaml uses deprecated "tddTestGlobs". Migrate to "tdd.testPathPatterns".`
443
+ : `no deprecated "tddTestGlobs" key detected in ${RUNTIME_ROOT}/config.yaml`
444
+ });
433
445
  const expectedMode = parsedConfig.promptGuardMode === "strict" ? "strict" : "advisory";
434
446
  const promptGuardPath = path.join(projectRoot, RUNTIME_ROOT, "hooks", "prompt-guard.sh");
435
447
  let promptGuardModeOk = false;
@@ -1191,6 +1203,62 @@ export async function doctorChecks(projectRoot, options = {}) {
1191
1203
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "harness-gaps.json")),
1192
1204
  details: `${RUNTIME_ROOT}/state/harness-gaps.json must exist for tiered harness capability tracking`
1193
1205
  });
1206
+ const adapterManifestPath = path.join(projectRoot, RUNTIME_ROOT, "adapters", "manifest.json");
1207
+ const adapterManifestExists = await exists(adapterManifestPath);
1208
+ checks.push({
1209
+ name: "state:adapter_manifest_exists",
1210
+ ok: adapterManifestExists,
1211
+ details: `${RUNTIME_ROOT}/adapters/manifest.json must exist for harness adapter provenance`
1212
+ });
1213
+ if (adapterManifestExists) {
1214
+ let harnessesOk = false;
1215
+ let harnessesDetails = "";
1216
+ let sourcesOk = false;
1217
+ let sourcesDetails = "";
1218
+ try {
1219
+ const parsed = JSON.parse(await fs.readFile(adapterManifestPath, "utf8"));
1220
+ const manifestHarnesses = Array.isArray(parsed.harnesses)
1221
+ ? parsed.harnesses.filter((entry) => typeof entry === "string")
1222
+ : [];
1223
+ const expectedHarnesses = configuredHarnesses.length > 0
1224
+ ? [...new Set(configuredHarnesses)].sort()
1225
+ : null;
1226
+ const actualHarnesses = [...new Set(manifestHarnesses)].sort();
1227
+ harnessesOk = expectedHarnesses
1228
+ ? actualHarnesses.length === expectedHarnesses.length &&
1229
+ actualHarnesses.every((harness, index) => harness === expectedHarnesses[index])
1230
+ : actualHarnesses.length > 0;
1231
+ harnessesDetails = expectedHarnesses
1232
+ ? harnessesOk
1233
+ ? `adapter manifest harnesses match config.yaml: ${actualHarnesses.join(", ")}`
1234
+ : `adapter manifest harnesses [${actualHarnesses.join(", ")}] do not match config.yaml [${expectedHarnesses.join(", ")}]`
1235
+ : harnessesOk
1236
+ ? `adapter manifest declares harnesses: ${actualHarnesses.join(", ")}`
1237
+ : "adapter manifest must declare at least one harness";
1238
+ const commandSource = typeof parsed.commandSource === "string" ? parsed.commandSource.trim() : "";
1239
+ const skillSource = typeof parsed.skillSource === "string" ? parsed.skillSource.trim() : "";
1240
+ sourcesOk = commandSource.length > 0 && skillSource.length > 0;
1241
+ sourcesDetails = sourcesOk
1242
+ ? `adapter manifest source globs are set (commandSource=${commandSource}; skillSource=${skillSource})`
1243
+ : "adapter manifest must include non-empty commandSource and skillSource";
1244
+ }
1245
+ catch {
1246
+ harnessesOk = false;
1247
+ harnessesDetails = "adapter manifest must be valid JSON with a harnesses array";
1248
+ sourcesOk = false;
1249
+ sourcesDetails = "adapter manifest must be valid JSON with source globs";
1250
+ }
1251
+ checks.push({
1252
+ name: "state:adapter_manifest_harnesses",
1253
+ ok: harnessesOk,
1254
+ details: harnessesDetails
1255
+ });
1256
+ checks.push({
1257
+ name: "state:adapter_manifest_sources",
1258
+ ok: sourcesOk,
1259
+ details: sourcesDetails
1260
+ });
1261
+ }
1194
1262
  const contextModeStatePath = path.join(projectRoot, RUNTIME_ROOT, "state", "context-mode.json");
1195
1263
  checks.push({
1196
1264
  name: "state:context_mode_exists",
@@ -1276,7 +1344,7 @@ export async function doctorChecks(projectRoot, options = {}) {
1276
1344
  name: "flow_state:track",
1277
1345
  ok: skippedConsistent,
1278
1346
  details: skippedConsistent
1279
- ? `active track "${activeTrack}" (${trackStageList.length}/${COMMAND_FILE_ORDER.length} stages: ${trackStageList.join(" → ")})${expectedSkipped.length > 0 ? `; skippedStages=${expectedSkipped.join(", ")}` : ""}`
1347
+ ? `active track "${activeTrack}" (${trackStageList.length}/${FLOW_STAGES.length} stages: ${trackStageList.join(" → ")})${expectedSkipped.length > 0 ? `; skippedStages=${expectedSkipped.join(", ")}` : ""}`
1280
1348
  : `track "${activeTrack}" expects skippedStages=[${expectedSkipped.join(", ")}] but flow-state has [${skippedFromState.join(", ")}] — run \`cclaw sync\` to repair`
1281
1349
  });
1282
1350
  if (parsedConfig?.trackHeuristics) {
@@ -1441,7 +1509,7 @@ export async function doctorChecks(projectRoot, options = {}) {
1441
1509
  ? "no legacy .cclaw/features snapshot entries remain"
1442
1510
  : `legacy snapshot entries still present (read-only): ${legacyWorkspaceEntries.join(", ")}`
1443
1511
  });
1444
- const staleStages = Object.keys(flowState.staleStages).filter((value) => COMMAND_FILE_ORDER.includes(value));
1512
+ const staleStages = Object.keys(flowState.staleStages).filter((value) => FLOW_STAGES.includes(value));
1445
1513
  checks.push({
1446
1514
  name: "state:stale_stages_resolved",
1447
1515
  ok: staleStages.length === 0,
@@ -1667,10 +1735,10 @@ export async function doctorChecks(projectRoot, options = {}) {
1667
1735
  const stageOrder = parsed.stage_order;
1668
1736
  const stageGates = parsed.stage_gates;
1669
1737
  const hasStageOrder = Array.isArray(stageOrder) &&
1670
- COMMAND_FILE_ORDER.every((stage) => stageOrder.includes(stage));
1738
+ FLOW_STAGES.every((stage) => stageOrder.includes(stage));
1671
1739
  const hasStageGates = typeof stageGates === "object" &&
1672
1740
  stageGates !== null &&
1673
- COMMAND_FILE_ORDER.every((stage) => Array.isArray(stageGates[stage]));
1741
+ FLOW_STAGES.every((stage) => Array.isArray(stageGates[stage]));
1674
1742
  hasRules = hasCoreLists && hasStageOrder && hasStageGates;
1675
1743
  }
1676
1744
  catch {
@@ -43,6 +43,14 @@ export interface RetroState {
43
43
  * automatic step.
44
44
  * - `archived` — archive completed in this session (transient — archive
45
45
  * resets flow-state so this value does not persist between runs).
46
+ *
47
+ * Layer separation (intentional):
48
+ * - `next: "done"` in stage schema means "the flow stage chain ended".
49
+ * - `shipSubstate: "archived"` is closeout-machine progress after ship.
50
+ * - `shipSubstate: "idle"` is the default closeout value before ship.
51
+ *
52
+ * These are not duplicates: `done` lives in stage transitions; `archived` /
53
+ * `idle` live in closeout lifecycle state.
46
54
  */
47
55
  export declare const SHIP_SUBSTATES: readonly ["idle", "retro_review", "compound_review", "ready_to_archive", "archived"];
48
56
  export type ShipSubstate = (typeof SHIP_SUBSTATES)[number];
@@ -1,4 +1,3 @@
1
- import { COMMAND_FILE_ORDER } from "./constants.js";
2
1
  import { buildTransitionRules, orderedStageSchemas, stageGateIds, stageRecommendedGateIds } from "./content/stage-schema.js";
3
2
  import { FLOW_STAGES, FLOW_TRACKS, TRACK_STAGES } from "./types.js";
4
3
  export const TRANSITION_RULES = buildTransitionRules();
@@ -17,6 +16,14 @@ export const TRANSITION_RULES = buildTransitionRules();
17
16
  * automatic step.
18
17
  * - `archived` — archive completed in this session (transient — archive
19
18
  * resets flow-state so this value does not persist between runs).
19
+ *
20
+ * Layer separation (intentional):
21
+ * - `next: "done"` in stage schema means "the flow stage chain ended".
22
+ * - `shipSubstate: "archived"` is closeout-machine progress after ship.
23
+ * - `shipSubstate: "idle"` is the default closeout value before ship.
24
+ *
25
+ * These are not duplicates: `done` lives in stage transitions; `archived` /
26
+ * `idle` live in closeout lifecycle state.
20
27
  */
21
28
  export const SHIP_SUBSTATES = [
22
29
  "idle",
@@ -98,11 +105,7 @@ export function nextStage(stage, track = "standard") {
98
105
  const ordered = TRACK_STAGES[track];
99
106
  const index = ordered.indexOf(stage);
100
107
  if (index < 0) {
101
- const fallback = COMMAND_FILE_ORDER.indexOf(stage);
102
- if (fallback < 0 || fallback === COMMAND_FILE_ORDER.length - 1) {
103
- return null;
104
- }
105
- return COMMAND_FILE_ORDER[fallback + 1];
108
+ return null;
106
109
  }
107
110
  if (index === ordered.length - 1) {
108
111
  return null;
@@ -116,11 +119,11 @@ export function previousStage(stage, track = "standard") {
116
119
  return null;
117
120
  }
118
121
  if (index < 0) {
119
- const fallback = COMMAND_FILE_ORDER.indexOf(stage);
122
+ const fallback = FLOW_STAGES.indexOf(stage);
120
123
  if (fallback <= 0) {
121
124
  return null;
122
125
  }
123
- return COMMAND_FILE_ORDER[fallback - 1];
126
+ return FLOW_STAGES[fallback - 1];
124
127
  }
125
128
  return ordered[index - 1];
126
129
  }