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.
- package/README.md +3 -1
- package/dist/artifact-linter.d.ts +7 -0
- package/dist/artifact-linter.js +169 -8
- package/dist/config.d.ts +6 -6
- package/dist/config.js +22 -0
- package/dist/constants.d.ts +10 -1
- package/dist/constants.js +19 -10
- package/dist/content/contracts.d.ts +1 -1
- package/dist/content/contracts.js +1 -1
- package/dist/content/{harnesses-doc.js → harness-doc.js} +32 -1
- package/dist/content/harness-playbooks.js +4 -4
- package/dist/content/ideate-command.js +19 -19
- package/dist/content/skills.js +2 -2
- package/dist/content/stage-schema.js +54 -15
- package/dist/content/stages/design.js +2 -2
- package/dist/content/stages/review.js +1 -1
- package/dist/content/stages/ship.js +2 -0
- package/dist/content/stages/tdd.js +8 -4
- package/dist/content/templates.js +4 -3
- package/dist/delegation.js +107 -26
- package/dist/doctor.js +77 -9
- package/dist/flow-state.d.ts +8 -0
- package/dist/flow-state.js +11 -8
- package/dist/gate-evidence.js +26 -2
- package/dist/harness-adapters.d.ts +2 -2
- package/dist/harness-adapters.js +2 -2
- package/dist/install.js +28 -6
- package/dist/internal/advance-stage.js +53 -16
- package/dist/internal/detect-public-api-changes.d.ts +5 -0
- package/dist/internal/detect-public-api-changes.js +45 -0
- package/dist/policy.js +3 -2
- package/dist/retro-gate.js +30 -3
- package/dist/run-persistence.js +16 -5
- package/dist/tdd-cycle.js +19 -1
- package/dist/types.d.ts +6 -1
- package/package.json +4 -1
- /package/dist/content/{harnesses-doc.d.ts → harness-doc.d.ts} +0 -0
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import {
|
|
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: "
|
|
202
|
-
when: "
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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:
|
|
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
|
-
"
|
|
824
|
+
"skip_tdd_stage",
|
|
824
825
|
"ship_with_critical_findings",
|
|
825
826
|
"implement_in_brainstorm",
|
|
826
827
|
"manual_edit_generated",
|
package/dist/delegation.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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}/${
|
|
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) =>
|
|
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
|
-
|
|
1738
|
+
FLOW_STAGES.every((stage) => stageOrder.includes(stage));
|
|
1671
1739
|
const hasStageGates = typeof stageGates === "object" &&
|
|
1672
1740
|
stageGates !== null &&
|
|
1673
|
-
|
|
1741
|
+
FLOW_STAGES.every((stage) => Array.isArray(stageGates[stage]));
|
|
1674
1742
|
hasRules = hasCoreLists && hasStageOrder && hasStageGates;
|
|
1675
1743
|
}
|
|
1676
1744
|
catch {
|
package/dist/flow-state.d.ts
CHANGED
|
@@ -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];
|
package/dist/flow-state.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
122
|
+
const fallback = FLOW_STAGES.indexOf(stage);
|
|
120
123
|
if (fallback <= 0) {
|
|
121
124
|
return null;
|
|
122
125
|
}
|
|
123
|
-
return
|
|
126
|
+
return FLOW_STAGES[fallback - 1];
|
|
124
127
|
}
|
|
125
128
|
return ordered[index - 1];
|
|
126
129
|
}
|