cclaw-cli 0.46.15 → 0.47.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/dist/artifact-linter.js +126 -8
- package/dist/content/stage-schema.js +21 -8
- package/dist/delegation.js +19 -4
- package/dist/gate-evidence.js +6 -1
- package/dist/internal/advance-stage.js +53 -16
- package/dist/retro-gate.js +11 -1
- package/dist/run-persistence.js +11 -1
- package/dist/tdd-cycle.js +19 -1
- package/package.json +1 -1
package/dist/artifact-linter.js
CHANGED
|
@@ -12,25 +12,52 @@ async function resolveArtifactPath(projectRoot, fileName) {
|
|
|
12
12
|
function normalizeHeadingTitle(title) {
|
|
13
13
|
return title.trim().replace(/\s+/g, " ");
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Collect H2 sections and body content (`## Section Name`).
|
|
17
|
+
*
|
|
18
|
+
* - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
|
|
19
|
+
* commented `## Approaches` inside an example doesn't open a phantom
|
|
20
|
+
* section and swallow real content.
|
|
21
|
+
* - When the same heading appears more than once at the top level we
|
|
22
|
+
* concatenate the bodies rather than silently overwriting the earlier
|
|
23
|
+
* occurrence. This keeps lint rules honest when authors split a section
|
|
24
|
+
* into multiple passes.
|
|
25
|
+
*/
|
|
16
26
|
function extractH2Sections(markdown) {
|
|
17
27
|
const sections = new Map();
|
|
18
28
|
const lines = markdown.split(/\r?\n/);
|
|
19
29
|
let currentHeading = null;
|
|
20
30
|
let buffer = [];
|
|
31
|
+
let fenced = null;
|
|
21
32
|
const flush = () => {
|
|
22
33
|
if (currentHeading === null)
|
|
23
34
|
return;
|
|
24
|
-
sections.
|
|
35
|
+
const existing = sections.get(currentHeading);
|
|
36
|
+
const body = buffer.join("\n");
|
|
37
|
+
sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
|
|
25
38
|
};
|
|
26
39
|
for (const line of lines) {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
const fenceMatch = /^(```|~~~)/u.exec(line);
|
|
41
|
+
if (fenceMatch) {
|
|
42
|
+
if (fenced === null) {
|
|
43
|
+
fenced = fenceMatch[1] ?? null;
|
|
44
|
+
}
|
|
45
|
+
else if (line.startsWith(fenced)) {
|
|
46
|
+
fenced = null;
|
|
47
|
+
}
|
|
48
|
+
if (currentHeading !== null)
|
|
49
|
+
buffer.push(line);
|
|
32
50
|
continue;
|
|
33
51
|
}
|
|
52
|
+
if (fenced === null) {
|
|
53
|
+
const match = /^##\s+(.+)$/u.exec(line);
|
|
54
|
+
if (match) {
|
|
55
|
+
flush();
|
|
56
|
+
currentHeading = normalizeHeadingTitle(match[1] ?? "");
|
|
57
|
+
buffer = [];
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
34
61
|
if (currentHeading !== null) {
|
|
35
62
|
buffer.push(line);
|
|
36
63
|
}
|
|
@@ -869,6 +896,49 @@ export async function lintArtifact(projectRoot, stage) {
|
|
|
869
896
|
details: learnings.details
|
|
870
897
|
});
|
|
871
898
|
}
|
|
899
|
+
if (stage === "brainstorm") {
|
|
900
|
+
// Brainstorm Iron Law: "NO ARTIFACT IS COMPLETE WITHOUT AN EXPLICITLY
|
|
901
|
+
// APPROVED DIRECTION — SILENCE IS NOT APPROVAL." Previously this was
|
|
902
|
+
// prose-only — nothing failed when the Selected Direction section
|
|
903
|
+
// omitted an approval marker, or when the Approaches table collapsed
|
|
904
|
+
// to a single row (defeating the "2-3 distinct approaches" gate).
|
|
905
|
+
const approachesBody = sectionBodyByName(sections, "Approaches");
|
|
906
|
+
if (approachesBody !== null) {
|
|
907
|
+
const tableRows = approachesBody
|
|
908
|
+
.split(/\r?\n/u)
|
|
909
|
+
.map((line) => line.trim())
|
|
910
|
+
.filter((line) => line.startsWith("|"))
|
|
911
|
+
.filter((line) => !/^\|\s*[-: |]+\|\s*$/u.test(line))
|
|
912
|
+
.filter((line) => !/^\|\s*approach\b/iu.test(line));
|
|
913
|
+
const bulletRows = approachesBody
|
|
914
|
+
.split(/\r?\n/u)
|
|
915
|
+
.map((line) => line.trim())
|
|
916
|
+
.filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
|
|
917
|
+
const rowCount = Math.max(tableRows.length, bulletRows.length);
|
|
918
|
+
findings.push({
|
|
919
|
+
section: "Distinct Approaches Enforcement",
|
|
920
|
+
required: true,
|
|
921
|
+
rule: "Approaches section must document at least 2 distinct approaches so the Iron Law comparison is meaningful.",
|
|
922
|
+
found: rowCount >= 2,
|
|
923
|
+
details: rowCount >= 2
|
|
924
|
+
? `Detected ${rowCount} approach row(s).`
|
|
925
|
+
: `Detected ${rowCount} approach row(s); at least 2 required.`
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const directionBody = sectionBodyByName(sections, "Selected Direction");
|
|
929
|
+
if (directionBody !== null) {
|
|
930
|
+
const approvalMarker = /\bapprov(?:ed|al)\b/iu.test(directionBody);
|
|
931
|
+
findings.push({
|
|
932
|
+
section: "Direction Approval Marker",
|
|
933
|
+
required: true,
|
|
934
|
+
rule: "Selected Direction section must state an explicit approval marker (for example `Approval: approved` or `Approved by: user`).",
|
|
935
|
+
found: approvalMarker,
|
|
936
|
+
details: approvalMarker
|
|
937
|
+
? "Approval marker present in Selected Direction."
|
|
938
|
+
: "No explicit `approved`/`approval` marker found in Selected Direction."
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
872
942
|
if (stage === "plan") {
|
|
873
943
|
const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
|
|
874
944
|
headingPresent(sections, "No-Placeholder Scan") ||
|
|
@@ -914,12 +984,13 @@ export async function lintArtifact(projectRoot, stage) {
|
|
|
914
984
|
});
|
|
915
985
|
}
|
|
916
986
|
if (stage === "scope") {
|
|
987
|
+
const lockedDecisionsBody = sectionBodyByName(sections, "Locked Decisions (D-XX)") ?? "";
|
|
917
988
|
const strictScopeGuards = parsedFrontmatter.hasFrontmatter ||
|
|
918
989
|
headingPresent(sections, "Locked Decisions (D-XX)");
|
|
919
990
|
const scopeSections = [
|
|
920
991
|
sectionBodyByName(sections, "In Scope / Out of Scope") ?? "",
|
|
921
992
|
sectionBodyByName(sections, "Scope Summary") ?? "",
|
|
922
|
-
|
|
993
|
+
lockedDecisionsBody
|
|
923
994
|
].join("\n");
|
|
924
995
|
const reductionHits = collectPatternHits(scopeSections, SCOPE_REDUCTION_PATTERNS);
|
|
925
996
|
findings.push({
|
|
@@ -931,6 +1002,45 @@ export async function lintArtifact(projectRoot, stage) {
|
|
|
931
1002
|
? "No scope-reduction phrases detected in scope boundary sections."
|
|
932
1003
|
: `Detected scope-reduction phrase(s): ${reductionHits.join(", ")}.`
|
|
933
1004
|
});
|
|
1005
|
+
// When the Locked Decisions section is present we must enforce the
|
|
1006
|
+
// D-XX ID contract at runtime (previously this was prose-only in the
|
|
1007
|
+
// artifactValidation rule). Empty body, missing IDs, and duplicate
|
|
1008
|
+
// IDs all fail the lint; absence of the section remains advisory so
|
|
1009
|
+
// scope stays optional for small/quick tracks.
|
|
1010
|
+
if (headingPresent(sections, "Locked Decisions (D-XX)")) {
|
|
1011
|
+
const decisionIds = extractDecisionIds(lockedDecisionsBody);
|
|
1012
|
+
const bulletLines = lockedDecisionsBody
|
|
1013
|
+
.split(/\r?\n/u)
|
|
1014
|
+
.map((line) => line.trim())
|
|
1015
|
+
.filter((line) => /^(?:[-*]|\|)\s+\S/u.test(line));
|
|
1016
|
+
const orphanBullets = bulletLines.filter((line) => !/\bD-\d+\b/u.test(line));
|
|
1017
|
+
const duplicateIds = (() => {
|
|
1018
|
+
const all = lockedDecisionsBody.match(/\bD-\d+\b/gu) ?? [];
|
|
1019
|
+
const counts = new Map();
|
|
1020
|
+
for (const id of all)
|
|
1021
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
1022
|
+
return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
|
|
1023
|
+
})();
|
|
1024
|
+
const issues = [];
|
|
1025
|
+
if (decisionIds.length === 0 && bulletLines.length === 0) {
|
|
1026
|
+
issues.push("section is empty");
|
|
1027
|
+
}
|
|
1028
|
+
if (orphanBullets.length > 0) {
|
|
1029
|
+
issues.push(`${orphanBullets.length} bullet(s) missing a D-XX ID`);
|
|
1030
|
+
}
|
|
1031
|
+
if (duplicateIds.length > 0) {
|
|
1032
|
+
issues.push(`duplicate IDs: ${duplicateIds.join(", ")}`);
|
|
1033
|
+
}
|
|
1034
|
+
findings.push({
|
|
1035
|
+
section: "Locked Decisions ID Integrity",
|
|
1036
|
+
required: true,
|
|
1037
|
+
rule: "Locked Decisions section must list each decision with a unique stable D-XX ID.",
|
|
1038
|
+
found: issues.length === 0,
|
|
1039
|
+
details: issues.length === 0
|
|
1040
|
+
? `${decisionIds.length} decision ID(s) recorded with no duplicates.`
|
|
1041
|
+
: issues.join("; ")
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
934
1044
|
}
|
|
935
1045
|
const passed = findings.every((f) => !f.required || f.found);
|
|
936
1046
|
return { stage, file: relFile, passed, findings };
|
|
@@ -1198,6 +1308,14 @@ export async function checkReviewVerdictConsistency(projectRoot) {
|
|
|
1198
1308
|
if (finalVerdict === "APPROVED" && (openCriticalCount > 0 || shipBlockerCount > 0)) {
|
|
1199
1309
|
errors.push(`Final Verdict is APPROVED but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Use BLOCKED or APPROVED_WITH_CONCERNS.`);
|
|
1200
1310
|
}
|
|
1311
|
+
// APPROVED_WITH_CONCERNS is intended for Important/Suggestion findings
|
|
1312
|
+
// the author has accepted. An *open* Critical finding or an active
|
|
1313
|
+
// shipBlocker must route through BLOCKED (review_verdict_blocked gate)
|
|
1314
|
+
// rather than pass as a concession — previously this slipped through.
|
|
1315
|
+
if (finalVerdict === "APPROVED_WITH_CONCERNS" &&
|
|
1316
|
+
(openCriticalCount > 0 || shipBlockerCount > 0)) {
|
|
1317
|
+
errors.push(`Final Verdict is APPROVED_WITH_CONCERNS but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Resolve them or use BLOCKED.`);
|
|
1318
|
+
}
|
|
1201
1319
|
return {
|
|
1202
1320
|
ok: errors.length === 0,
|
|
1203
1321
|
errors,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COMMAND_FILE_ORDER } from "../constants.js";
|
|
2
|
+
import { FLOW_TRACKS, TRACK_STAGES } from "../types.js";
|
|
2
3
|
import { BRAINSTORM, SCOPE, DESIGN, SPEC, PLAN, TDD, REVIEW, SHIP } from "./stages/index.js";
|
|
3
4
|
import { tddStageForTrack } from "./stages/tdd.js";
|
|
4
5
|
const REQUIRED_GATE_IDS = {
|
|
@@ -266,15 +267,27 @@ export function nextCclawCommand(stage) {
|
|
|
266
267
|
}
|
|
267
268
|
export function buildTransitionRules() {
|
|
268
269
|
const rules = [];
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
// Derive transitions from every track so medium/quick (which skip stages)
|
|
272
|
+
// get their neighbour edges registered alongside the standard chain.
|
|
273
|
+
// Previously only the standard track produced rules, so `canTransition`
|
|
274
|
+
// returned false for legitimate medium/quick transitions (e.g. brainstorm
|
|
275
|
+
// -> spec on medium) even though `nextStage` correctly advanced them.
|
|
276
|
+
for (const track of FLOW_TRACKS) {
|
|
277
|
+
const ordered = TRACK_STAGES[track];
|
|
278
|
+
for (let i = 0; i < ordered.length - 1; i += 1) {
|
|
279
|
+
const from = ordered[i];
|
|
280
|
+
const to = ordered[i + 1];
|
|
281
|
+
const key = `${from}->${to}`;
|
|
282
|
+
if (seen.has(key))
|
|
283
|
+
continue;
|
|
284
|
+
seen.add(key);
|
|
285
|
+
rules.push({
|
|
286
|
+
from,
|
|
287
|
+
to,
|
|
288
|
+
guards: stageGateIds(from, track)
|
|
289
|
+
});
|
|
272
290
|
}
|
|
273
|
-
rules.push({
|
|
274
|
-
from: schema.stage,
|
|
275
|
-
to: schema.next,
|
|
276
|
-
guards: stageGateIds(schema.stage)
|
|
277
|
-
});
|
|
278
291
|
}
|
|
279
292
|
// Review can explicitly route back to TDD when the verdict is BLOCKED.
|
|
280
293
|
rules.push({
|
package/dist/delegation.js
CHANGED
|
@@ -126,6 +126,13 @@ export async function appendDelegation(projectRoot, entry) {
|
|
|
126
126
|
if (!Array.isArray(stamped.evidenceRefs)) {
|
|
127
127
|
stamped.evidenceRefs = [];
|
|
128
128
|
}
|
|
129
|
+
// Idempotency: if a caller (or a retried hook) tries to append a row
|
|
130
|
+
// with a spanId that already exists in the ledger, treat it as a no-op
|
|
131
|
+
// instead of growing the log with duplicate entries that subsequent
|
|
132
|
+
// delegation checks would mis-count.
|
|
133
|
+
if (prior.entries.some((existing) => existing.spanId === stamped.spanId)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
129
136
|
const ledger = {
|
|
130
137
|
runId: activeRunId,
|
|
131
138
|
entries: [...prior.entries, stamped]
|
|
@@ -201,11 +208,19 @@ export async function checkMandatoryDelegations(projectRoot, stage) {
|
|
|
201
208
|
if (hasWaived) {
|
|
202
209
|
waived.push(agent);
|
|
203
210
|
}
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
211
|
+
// Evidence gating for `completed` rows has two triggers:
|
|
212
|
+
// 1. The aggregate expected mode is role-switch (no isolated harness
|
|
213
|
+
// available), so every completion implicitly ran as role-switch.
|
|
214
|
+
// 2. Any completed row is explicitly stamped `fulfillmentMode:
|
|
215
|
+
// "role-switch"` — even in a mixed install. This closes the loop
|
|
216
|
+
// where a Codex session logs a role-switch completion inside a
|
|
217
|
+
// claude+codex project: the aggregate expectedMode is "isolated"
|
|
218
|
+
// (claude wins), so the role-switch row would previously sail
|
|
219
|
+
// through without evidenceRefs.
|
|
220
|
+
const hasExplicitRoleSwitchRow = completedRows.some((e) => e.fulfillmentMode === "role-switch");
|
|
221
|
+
const evidenceRequired = expectedMode === "role-switch" || hasExplicitRoleSwitchRow;
|
|
207
222
|
if (hasCompleted &&
|
|
208
|
-
|
|
223
|
+
evidenceRequired &&
|
|
209
224
|
!completedRows.some((e) => Array.isArray(e.evidenceRefs) && e.evidenceRefs.length > 0)) {
|
|
210
225
|
missingEvidence.push(agent);
|
|
211
226
|
}
|
package/dist/gate-evidence.js
CHANGED
|
@@ -266,7 +266,12 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
|
|
|
266
266
|
.map((line) => line.trim())
|
|
267
267
|
.filter((line) => line.length > 0)
|
|
268
268
|
.filter((line) => !/^\|?(?:[-:\s|])+$/u.test(line));
|
|
269
|
-
|
|
269
|
+
// `<fill-in>` needs its own check because `\b` does not match
|
|
270
|
+
// around `<`/`>` (non-word characters), so the previous combined
|
|
271
|
+
// pattern `\b(?:...|<fill-in>)\b` silently never matched placeholder
|
|
272
|
+
// templates that used angle-bracket form.
|
|
273
|
+
const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending)\b/iu.test(line) &&
|
|
274
|
+
!/<fill-in>/iu.test(line));
|
|
270
275
|
if (nonPlaceholder.length === 0) {
|
|
271
276
|
missingSections.push(`${section} (empty or placeholder)`);
|
|
272
277
|
}
|
|
@@ -16,21 +16,36 @@ function unique(values) {
|
|
|
16
16
|
const TEST_COMMAND_HINT_PATTERN = /\b(?:npm test|pnpm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
|
|
17
17
|
const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}\b/iu;
|
|
18
18
|
const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const SHIP_FINALIZATION_MODE_PATTERN = /\bFINALIZE_(?:MERGE_LOCAL|OPEN_PR|QUEUE|HANDOFF|SKIP)\b/u;
|
|
20
|
+
// Per-gate validators keyed by `${stage}:${gateId}`. Returning a non-null
|
|
21
|
+
// string surfaces the reason as an `advance-stage` failure so evidence is
|
|
22
|
+
// guaranteed to carry the structural breadcrumbs downstream tooling
|
|
23
|
+
// expects. Previously only `tdd:tdd_verified_before_complete` was checked.
|
|
24
|
+
const GATE_EVIDENCE_VALIDATORS = {
|
|
25
|
+
"tdd:tdd_verified_before_complete": (evidence) => {
|
|
26
|
+
if (!TEST_COMMAND_HINT_PATTERN.test(evidence)) {
|
|
27
|
+
return "must include the fresh verification command that was run (for example `npm test`, `pytest`, `go test`, or equivalent).";
|
|
28
|
+
}
|
|
29
|
+
if (!SHA_WITH_LABEL_PATTERN.test(evidence)) {
|
|
30
|
+
return "must include a commit SHA token prefixed with `sha` or `commit` (for example `sha: abc1234`).";
|
|
31
|
+
}
|
|
32
|
+
if (!PASS_STATUS_PATTERN.test(evidence)) {
|
|
33
|
+
return "must include explicit success status (for example `PASS` or `GREEN`).";
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
},
|
|
37
|
+
"ship:ship_finalization_executed": (evidence) => {
|
|
38
|
+
if (!SHIP_FINALIZATION_MODE_PATTERN.test(evidence)) {
|
|
39
|
+
return "must name the finalization mode that ran (for example `FINALIZE_MERGE_LOCAL`, `FINALIZE_OPEN_PR`, `FINALIZE_HANDOFF`, `FINALIZE_QUEUE`, or `FINALIZE_SKIP`).";
|
|
40
|
+
}
|
|
21
41
|
return null;
|
|
22
42
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
if (!PASS_STATUS_PATTERN.test(trimmed)) {
|
|
31
|
-
return "must include explicit success status (for example `PASS` or `GREEN`).";
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
43
|
+
};
|
|
44
|
+
function validateGateEvidenceShape(stage, gateId, evidence) {
|
|
45
|
+
const validator = GATE_EVIDENCE_VALIDATORS[`${stage}:${gateId}`];
|
|
46
|
+
if (!validator)
|
|
47
|
+
return null;
|
|
48
|
+
return validator(evidence.trim());
|
|
34
49
|
}
|
|
35
50
|
function parseStringList(raw) {
|
|
36
51
|
if (!Array.isArray(raw))
|
|
@@ -58,10 +73,23 @@ function parseGuardEvidence(value) {
|
|
|
58
73
|
}
|
|
59
74
|
return next;
|
|
60
75
|
}
|
|
76
|
+
function emptyGateState() {
|
|
77
|
+
return {
|
|
78
|
+
required: [],
|
|
79
|
+
recommended: [],
|
|
80
|
+
conditional: [],
|
|
81
|
+
triggered: [],
|
|
82
|
+
passed: [],
|
|
83
|
+
blocked: []
|
|
84
|
+
};
|
|
85
|
+
}
|
|
61
86
|
function parseCandidateGateCatalog(value, fallback) {
|
|
62
87
|
const next = {};
|
|
63
88
|
for (const stage of FLOW_STAGES) {
|
|
64
|
-
|
|
89
|
+
// Guard against stale on-disk flow-state files that persisted a partial
|
|
90
|
+
// stageGateCatalog (missing a stage key). Previously `fallback[stage]`
|
|
91
|
+
// could be undefined and the spread below would throw at runtime.
|
|
92
|
+
const base = fallback[stage] ?? emptyGateState();
|
|
65
93
|
next[stage] = {
|
|
66
94
|
required: [...base.required],
|
|
67
95
|
recommended: [...base.recommended],
|
|
@@ -81,7 +109,7 @@ function parseCandidateGateCatalog(value, fallback) {
|
|
|
81
109
|
continue;
|
|
82
110
|
}
|
|
83
111
|
const typed = rawStage;
|
|
84
|
-
const base = fallback[stage];
|
|
112
|
+
const base = fallback[stage] ?? emptyGateState();
|
|
85
113
|
const allowed = new Set([...base.required, ...base.recommended, ...base.conditional]);
|
|
86
114
|
const conditional = new Set(base.conditional);
|
|
87
115
|
const passed = unique(parseStringList(typed.passed)).filter((gateId) => allowed.has(gateId));
|
|
@@ -114,13 +142,22 @@ function coerceCandidateFlowState(raw, fallback) {
|
|
|
114
142
|
const completedStages = unique(parseStringList(typed.completedStages).filter((stage) => isFlowStageValue(stage)));
|
|
115
143
|
const skippedStagesRaw = parseStringList(typed.skippedStages).filter((stage) => isFlowStageValue(stage));
|
|
116
144
|
const skippedStages = skippedStagesRaw.length > 0 ? skippedStagesRaw : fallback.skippedStages;
|
|
145
|
+
// When the candidate payload omits `guardEvidence` entirely we must keep
|
|
146
|
+
// the on-disk fallback — otherwise a partial update (e.g. a tooling call
|
|
147
|
+
// that only passes stage + passedGateIds) would silently wipe every
|
|
148
|
+
// previously recorded evidence string and fail the next
|
|
149
|
+
// `verifyCurrentStageGateEvidence` check.
|
|
150
|
+
const candidateEvidence = parseGuardEvidence(typed.guardEvidence);
|
|
151
|
+
const guardEvidence = typed.guardEvidence === undefined
|
|
152
|
+
? { ...fallback.guardEvidence }
|
|
153
|
+
: candidateEvidence;
|
|
117
154
|
return {
|
|
118
155
|
...fallback,
|
|
119
156
|
currentStage,
|
|
120
157
|
completedStages,
|
|
121
158
|
track,
|
|
122
159
|
skippedStages,
|
|
123
|
-
guardEvidence
|
|
160
|
+
guardEvidence,
|
|
124
161
|
stageGateCatalog: parseCandidateGateCatalog(typed.stageGateCatalog, fallback.stageGateCatalog)
|
|
125
162
|
};
|
|
126
163
|
}
|
package/dist/retro-gate.js
CHANGED
|
@@ -73,7 +73,17 @@ export async function evaluateRetroGate(projectRoot, state) {
|
|
|
73
73
|
compoundEntries = 0;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// A retro is considered complete when either:
|
|
77
|
+
// - at least one compound learning was promoted during the retro window, or
|
|
78
|
+
// - the operator explicitly skipped retro or compound (`retroSkipped` /
|
|
79
|
+
// `compoundSkipped` recorded in the closeout substate) after reviewing
|
|
80
|
+
// the draft. Previously the gate required `compoundEntries > 0`
|
|
81
|
+
// unconditionally, which dead-locked ship closeout whenever the retro
|
|
82
|
+
// yielded no new patterns worth promoting.
|
|
83
|
+
const explicitSkip = Boolean(state.closeout.retroSkipped || state.closeout.compoundSkipped);
|
|
84
|
+
const completed = required
|
|
85
|
+
? hasRetroArtifact && (compoundEntries > 0 || explicitSkip)
|
|
86
|
+
: true;
|
|
77
87
|
return {
|
|
78
88
|
required,
|
|
79
89
|
completed,
|
package/dist/run-persistence.js
CHANGED
|
@@ -235,7 +235,7 @@ function sanitizeCloseoutState(value) {
|
|
|
235
235
|
return fallback;
|
|
236
236
|
}
|
|
237
237
|
const typed = value;
|
|
238
|
-
|
|
238
|
+
let shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
|
|
239
239
|
const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
|
|
240
240
|
const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
|
|
241
241
|
const retroSkipped = typeof typed.retroSkipped === "boolean" ? typed.retroSkipped : undefined;
|
|
@@ -246,6 +246,16 @@ function sanitizeCloseoutState(value) {
|
|
|
246
246
|
const compoundPromoted = typeof promotedRaw === "number" && Number.isFinite(promotedRaw) && promotedRaw >= 0
|
|
247
247
|
? Math.floor(promotedRaw)
|
|
248
248
|
: 0;
|
|
249
|
+
// Demote shipSubstate when its retro invariant is violated on disk. A
|
|
250
|
+
// hand-edited flow-state could claim `ready_to_archive` or `compound_review`
|
|
251
|
+
// without ever going through the retro step, which would let `archive`
|
|
252
|
+
// proceed and skip the gate. Compound completion is not independently
|
|
253
|
+
// tracked in all flows (some runs rely on knowledge.jsonl + the retro
|
|
254
|
+
// window), so we only demote when the retro leg is missing outright.
|
|
255
|
+
const retroDone = retroAcceptedAt !== undefined || retroSkipped === true;
|
|
256
|
+
if (!retroDone && (shipSubstate === "ready_to_archive" || shipSubstate === "compound_review")) {
|
|
257
|
+
shipSubstate = "retro_review";
|
|
258
|
+
}
|
|
249
259
|
return {
|
|
250
260
|
shipSubstate,
|
|
251
261
|
retroDraftedAt,
|
package/dist/tdd-cycle.js
CHANGED
|
@@ -31,6 +31,7 @@ export function parseTddCycleLog(text) {
|
|
|
31
31
|
}
|
|
32
32
|
return out;
|
|
33
33
|
}
|
|
34
|
+
const SLICE_ID_PATTERN = /^S-\d+$/u;
|
|
34
35
|
export function validateTddCycleOrder(entries, options = {}) {
|
|
35
36
|
const targetRun = options.runId;
|
|
36
37
|
const filtered = targetRun
|
|
@@ -44,6 +45,15 @@ export function validateTddCycleOrder(entries, options = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
const issues = [];
|
|
46
47
|
const openRedSlices = [];
|
|
48
|
+
// Reject slices whose ID does not match the stable `S-<number>` contract.
|
|
49
|
+
// Entries that drop the slice field entirely were previously coerced to
|
|
50
|
+
// `S-unknown` and silently bucketed together, which means multiple distinct
|
|
51
|
+
// cycles could appear to share a RED/GREEN pair.
|
|
52
|
+
for (const slice of bySlice.keys()) {
|
|
53
|
+
if (!SLICE_ID_PATTERN.test(slice)) {
|
|
54
|
+
issues.push(`slice "${slice}": id must match /^S-\\d+$/ (e.g. S-1)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
47
57
|
for (const [slice, sliceEntries] of bySlice.entries()) {
|
|
48
58
|
let state = "need_red";
|
|
49
59
|
for (const entry of sliceEntries) {
|
|
@@ -79,7 +89,15 @@ export function validateTddCycleOrder(entries, options = {}) {
|
|
|
79
89
|
state = "green_done";
|
|
80
90
|
continue;
|
|
81
91
|
}
|
|
82
|
-
// refactor
|
|
92
|
+
// refactor — must preserve the passing state established by green.
|
|
93
|
+
if (entry.exitCode === undefined) {
|
|
94
|
+
issues.push(`slice ${slice}: refactor entry must record exitCode 0`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (entry.exitCode !== 0) {
|
|
98
|
+
issues.push(`slice ${slice}: refactor entry exitCode must be 0 (tests must stay green)`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
83
101
|
if (state !== "green_done") {
|
|
84
102
|
issues.push(`slice ${slice}: refactor logged before green`);
|
|
85
103
|
}
|