cclaw-cli 7.7.1 → 8.1.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 +210 -134
- package/dist/artifact-frontmatter.d.ts +51 -0
- package/dist/artifact-frontmatter.js +131 -0
- package/dist/artifact-paths.d.ts +7 -27
- package/dist/artifact-paths.js +20 -249
- package/dist/cancel.d.ts +16 -0
- package/dist/cancel.js +66 -0
- package/dist/cli.d.ts +2 -27
- package/dist/cli.js +90 -508
- package/dist/compound.d.ts +26 -0
- package/dist/compound.js +96 -0
- package/dist/config.d.ts +14 -51
- package/dist/config.js +23 -359
- package/dist/constants.d.ts +11 -18
- package/dist/constants.js +19 -106
- package/dist/content/antipatterns.d.ts +1 -0
- package/dist/content/antipatterns.js +109 -0
- package/dist/content/artifact-templates.d.ts +10 -0
- package/dist/content/artifact-templates.js +550 -0
- package/dist/content/cancel-command.d.ts +2 -2
- package/dist/content/cancel-command.js +25 -17
- package/dist/content/core-agents.d.ts +9 -233
- package/dist/content/core-agents.js +39 -768
- package/dist/content/decision-protocol.d.ts +1 -12
- package/dist/content/decision-protocol.js +27 -20
- package/dist/content/examples.d.ts +8 -42
- package/dist/content/examples.js +293 -425
- package/dist/content/idea-command.d.ts +2 -0
- package/dist/content/idea-command.js +38 -0
- package/dist/content/iron-laws.d.ts +4 -138
- package/dist/content/iron-laws.js +18 -197
- package/dist/content/meta-skill.d.ts +1 -3
- package/dist/content/meta-skill.js +57 -134
- package/dist/content/node-hooks.d.ts +12 -8
- package/dist/content/node-hooks.js +188 -838
- package/dist/content/recovery.d.ts +8 -0
- package/dist/content/recovery.js +179 -0
- package/dist/content/reference-patterns.d.ts +4 -13
- package/dist/content/reference-patterns.js +260 -389
- package/dist/content/research-playbooks.d.ts +8 -8
- package/dist/content/research-playbooks.js +108 -121
- package/dist/content/review-loop.d.ts +6 -192
- package/dist/content/review-loop.js +29 -731
- package/dist/content/skills.d.ts +8 -38
- package/dist/content/skills.js +681 -732
- package/dist/content/specialist-prompts/architect.d.ts +1 -0
- package/dist/content/specialist-prompts/architect.js +225 -0
- package/dist/content/specialist-prompts/brainstormer.d.ts +1 -0
- package/dist/content/specialist-prompts/brainstormer.js +168 -0
- package/dist/content/specialist-prompts/index.d.ts +2 -0
- package/dist/content/specialist-prompts/index.js +14 -0
- package/dist/content/specialist-prompts/planner.d.ts +1 -0
- package/dist/content/specialist-prompts/planner.js +182 -0
- package/dist/content/specialist-prompts/reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/reviewer.js +193 -0
- package/dist/content/specialist-prompts/security-reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/security-reviewer.js +133 -0
- package/dist/content/specialist-prompts/slice-builder.d.ts +1 -0
- package/dist/content/specialist-prompts/slice-builder.js +232 -0
- package/dist/content/stage-playbooks.d.ts +8 -0
- package/dist/content/stage-playbooks.js +404 -0
- package/dist/content/start-command.d.ts +2 -12
- package/dist/content/start-command.js +221 -207
- package/dist/flow-state.d.ts +21 -178
- package/dist/flow-state.js +67 -170
- package/dist/fs-utils.d.ts +6 -26
- package/dist/fs-utils.js +29 -162
- package/dist/gitignore.d.ts +2 -1
- package/dist/gitignore.js +51 -34
- package/dist/harness-detect.d.ts +10 -0
- package/dist/harness-detect.js +29 -0
- package/dist/install.d.ts +27 -15
- package/dist/install.js +230 -1342
- package/dist/knowledge-store.d.ts +19 -163
- package/dist/knowledge-store.js +56 -590
- package/dist/logger.d.ts +8 -3
- package/dist/logger.js +13 -4
- package/dist/orchestrator-routing.d.ts +29 -0
- package/dist/orchestrator-routing.js +156 -0
- package/dist/run-persistence.d.ts +7 -118
- package/dist/run-persistence.js +29 -845
- package/dist/runtime/run-hook.entry.d.ts +1 -3
- package/dist/runtime/run-hook.entry.js +19 -4
- package/dist/runtime/run-hook.mjs +13 -1024
- package/dist/types.d.ts +25 -261
- package/dist/types.js +8 -36
- package/package.json +6 -3
- package/dist/artifact-linter/brainstorm.d.ts +0 -2
- package/dist/artifact-linter/brainstorm.js +0 -353
- package/dist/artifact-linter/design.d.ts +0 -18
- package/dist/artifact-linter/design.js +0 -444
- package/dist/artifact-linter/findings-dedup.d.ts +0 -56
- package/dist/artifact-linter/findings-dedup.js +0 -232
- package/dist/artifact-linter/plan.d.ts +0 -2
- package/dist/artifact-linter/plan.js +0 -826
- package/dist/artifact-linter/review-army.d.ts +0 -49
- package/dist/artifact-linter/review-army.js +0 -520
- package/dist/artifact-linter/review.d.ts +0 -2
- package/dist/artifact-linter/review.js +0 -113
- package/dist/artifact-linter/scope.d.ts +0 -2
- package/dist/artifact-linter/scope.js +0 -158
- package/dist/artifact-linter/shared.d.ts +0 -637
- package/dist/artifact-linter/shared.js +0 -2163
- package/dist/artifact-linter/ship.d.ts +0 -2
- package/dist/artifact-linter/ship.js +0 -250
- package/dist/artifact-linter/spec.d.ts +0 -2
- package/dist/artifact-linter/spec.js +0 -176
- package/dist/artifact-linter/tdd.d.ts +0 -118
- package/dist/artifact-linter/tdd.js +0 -1404
- package/dist/artifact-linter.d.ts +0 -15
- package/dist/artifact-linter.js +0 -517
- package/dist/codex-feature-flag.d.ts +0 -58
- package/dist/codex-feature-flag.js +0 -193
- package/dist/content/closeout-guidance.d.ts +0 -14
- package/dist/content/closeout-guidance.js +0 -44
- package/dist/content/diff-command.d.ts +0 -1
- package/dist/content/diff-command.js +0 -43
- package/dist/content/harness-doc.d.ts +0 -1
- package/dist/content/harness-doc.js +0 -65
- package/dist/content/hook-events.d.ts +0 -9
- package/dist/content/hook-events.js +0 -23
- package/dist/content/hook-manifest.d.ts +0 -81
- package/dist/content/hook-manifest.js +0 -156
- package/dist/content/hooks.d.ts +0 -11
- package/dist/content/hooks.js +0 -1972
- package/dist/content/idea.d.ts +0 -60
- package/dist/content/idea.js +0 -416
- package/dist/content/language-policy.d.ts +0 -2
- package/dist/content/language-policy.js +0 -13
- package/dist/content/learnings.d.ts +0 -6
- package/dist/content/learnings.js +0 -141
- package/dist/content/observe.d.ts +0 -19
- package/dist/content/observe.js +0 -86
- package/dist/content/opencode-plugin.d.ts +0 -1
- package/dist/content/opencode-plugin.js +0 -635
- package/dist/content/review-prompts.d.ts +0 -1
- package/dist/content/review-prompts.js +0 -104
- package/dist/content/runtime-shared-snippets.d.ts +0 -8
- package/dist/content/runtime-shared-snippets.js +0 -80
- package/dist/content/session-hooks.d.ts +0 -7
- package/dist/content/session-hooks.js +0 -107
- package/dist/content/skills-elicitation.d.ts +0 -1
- package/dist/content/skills-elicitation.js +0 -167
- package/dist/content/stage-command.d.ts +0 -2
- package/dist/content/stage-command.js +0 -17
- package/dist/content/stage-schema.d.ts +0 -117
- package/dist/content/stage-schema.js +0 -955
- package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
- package/dist/content/stages/_lint-metadata/index.js +0 -97
- package/dist/content/stages/brainstorm.d.ts +0 -2
- package/dist/content/stages/brainstorm.js +0 -184
- package/dist/content/stages/design.d.ts +0 -2
- package/dist/content/stages/design.js +0 -288
- package/dist/content/stages/index.d.ts +0 -8
- package/dist/content/stages/index.js +0 -11
- package/dist/content/stages/plan.d.ts +0 -2
- package/dist/content/stages/plan.js +0 -191
- package/dist/content/stages/review.d.ts +0 -2
- package/dist/content/stages/review.js +0 -240
- package/dist/content/stages/schema-types.d.ts +0 -203
- package/dist/content/stages/schema-types.js +0 -1
- package/dist/content/stages/scope.d.ts +0 -2
- package/dist/content/stages/scope.js +0 -254
- package/dist/content/stages/ship.d.ts +0 -2
- package/dist/content/stages/ship.js +0 -159
- package/dist/content/stages/spec.d.ts +0 -2
- package/dist/content/stages/spec.js +0 -170
- package/dist/content/stages/tdd.d.ts +0 -4
- package/dist/content/stages/tdd.js +0 -273
- package/dist/content/state-contracts.d.ts +0 -1
- package/dist/content/state-contracts.js +0 -63
- package/dist/content/status-command.d.ts +0 -4
- package/dist/content/status-command.js +0 -109
- package/dist/content/subagent-context-skills.d.ts +0 -4
- package/dist/content/subagent-context-skills.js +0 -279
- package/dist/content/subagents.d.ts +0 -3
- package/dist/content/subagents.js +0 -997
- package/dist/content/templates.d.ts +0 -26
- package/dist/content/templates.js +0 -1692
- package/dist/content/track-render-context.d.ts +0 -18
- package/dist/content/track-render-context.js +0 -53
- package/dist/content/tree-command.d.ts +0 -1
- package/dist/content/tree-command.js +0 -64
- package/dist/content/utility-skills.d.ts +0 -30
- package/dist/content/utility-skills.js +0 -160
- package/dist/content/view-command.d.ts +0 -2
- package/dist/content/view-command.js +0 -92
- package/dist/delegation.d.ts +0 -649
- package/dist/delegation.js +0 -1539
- package/dist/early-loop.d.ts +0 -70
- package/dist/early-loop.js +0 -302
- package/dist/execution-topology.d.ts +0 -44
- package/dist/execution-topology.js +0 -95
- package/dist/gate-evidence.d.ts +0 -85
- package/dist/gate-evidence.js +0 -631
- package/dist/harness-adapters.d.ts +0 -151
- package/dist/harness-adapters.js +0 -756
- package/dist/harness-selection.d.ts +0 -31
- package/dist/harness-selection.js +0 -214
- package/dist/hook-schema.d.ts +0 -6
- package/dist/hook-schema.js +0 -114
- package/dist/hook-schemas/claude-hooks.v1.json +0 -10
- package/dist/hook-schemas/codex-hooks.v1.json +0 -10
- package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
- package/dist/init-detect.d.ts +0 -2
- package/dist/init-detect.js +0 -50
- package/dist/internal/advance-stage/advance.d.ts +0 -89
- package/dist/internal/advance-stage/advance.js +0 -655
- package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
- package/dist/internal/advance-stage/cancel-run.js +0 -19
- package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
- package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
- package/dist/internal/advance-stage/helpers.d.ts +0 -14
- package/dist/internal/advance-stage/helpers.js +0 -145
- package/dist/internal/advance-stage/hook.d.ts +0 -8
- package/dist/internal/advance-stage/hook.js +0 -40
- package/dist/internal/advance-stage/parsers.d.ts +0 -72
- package/dist/internal/advance-stage/parsers.js +0 -357
- package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
- package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
- package/dist/internal/advance-stage/review-loop.d.ts +0 -16
- package/dist/internal/advance-stage/review-loop.js +0 -199
- package/dist/internal/advance-stage/rewind.d.ts +0 -14
- package/dist/internal/advance-stage/rewind.js +0 -108
- package/dist/internal/advance-stage/start-flow.d.ts +0 -13
- package/dist/internal/advance-stage/start-flow.js +0 -241
- package/dist/internal/advance-stage/verify.d.ts +0 -21
- package/dist/internal/advance-stage/verify.js +0 -185
- package/dist/internal/advance-stage.d.ts +0 -7
- package/dist/internal/advance-stage.js +0 -138
- package/dist/internal/cohesion-contract-stub.d.ts +0 -24
- package/dist/internal/cohesion-contract-stub.js +0 -148
- package/dist/internal/compound-readiness.d.ts +0 -23
- package/dist/internal/compound-readiness.js +0 -102
- package/dist/internal/detect-public-api-changes.d.ts +0 -5
- package/dist/internal/detect-public-api-changes.js +0 -45
- package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
- package/dist/internal/detect-supply-chain-changes.js +0 -138
- package/dist/internal/early-loop-status.d.ts +0 -7
- package/dist/internal/early-loop-status.js +0 -93
- package/dist/internal/envelope-validate.d.ts +0 -7
- package/dist/internal/envelope-validate.js +0 -66
- package/dist/internal/flow-state-repair.d.ts +0 -20
- package/dist/internal/flow-state-repair.js +0 -104
- package/dist/internal/plan-split-waves.d.ts +0 -190
- package/dist/internal/plan-split-waves.js +0 -764
- package/dist/internal/runtime-integrity.d.ts +0 -7
- package/dist/internal/runtime-integrity.js +0 -268
- package/dist/internal/slice-commit.d.ts +0 -7
- package/dist/internal/slice-commit.js +0 -619
- package/dist/internal/tdd-loop-status.d.ts +0 -14
- package/dist/internal/tdd-loop-status.js +0 -68
- package/dist/internal/tdd-red-evidence.d.ts +0 -7
- package/dist/internal/tdd-red-evidence.js +0 -153
- package/dist/internal/waiver-grant.d.ts +0 -62
- package/dist/internal/waiver-grant.js +0 -294
- package/dist/internal/wave-status.d.ts +0 -74
- package/dist/internal/wave-status.js +0 -506
- package/dist/managed-resources.d.ts +0 -53
- package/dist/managed-resources.js +0 -313
- package/dist/policy.d.ts +0 -10
- package/dist/policy.js +0 -167
- package/dist/retro-gate.d.ts +0 -9
- package/dist/retro-gate.js +0 -47
- package/dist/run-archive.d.ts +0 -61
- package/dist/run-archive.js +0 -391
- package/dist/runs.d.ts +0 -2
- package/dist/runs.js +0 -2
- package/dist/stack-detection.d.ts +0 -116
- package/dist/stack-detection.js +0 -489
- package/dist/streaming/event-stream.d.ts +0 -31
- package/dist/streaming/event-stream.js +0 -114
- package/dist/tdd-cycle.d.ts +0 -107
- package/dist/tdd-cycle.js +0 -289
- package/dist/tdd-verification-evidence.d.ts +0 -17
- package/dist/tdd-verification-evidence.js +0 -122
- package/dist/track-heuristics.d.ts +0 -27
- package/dist/track-heuristics.js +0 -154
- package/dist/util/slice-id.d.ts +0 -58
- package/dist/util/slice-id.js +0 -89
- package/dist/worktree-manager.d.ts +0 -20
- package/dist/worktree-manager.js +0 -108
|
@@ -1,1404 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { loadTddReadySlicePool, readDelegationLedger, readDelegationEvents, selectReadySlices } from "../delegation.js";
|
|
6
|
-
import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
|
|
7
|
-
import { exists } from "../fs-utils.js";
|
|
8
|
-
import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "../internal/plan-split-waves.js";
|
|
9
|
-
import { compareSliceIds } from "../util/slice-id.js";
|
|
10
|
-
import { extractAcceptanceCriterionIdsFromMarkdown, extractH2Sections, evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
|
|
11
|
-
const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
|
|
12
|
-
const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
|
|
13
|
-
const SLICES_INDEX_START = "<!-- auto-start: slices-index -->";
|
|
14
|
-
const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
|
|
15
|
-
const execFileAsync = promisify(execFile);
|
|
16
|
-
/**
|
|
17
|
-
* TDD stage linter.
|
|
18
|
-
*
|
|
19
|
-
* Source-of-truth ladder, in order of precedence:
|
|
20
|
-
*
|
|
21
|
-
* 1. **Phase events** in `delegation-events.jsonl` for the active run
|
|
22
|
-
* (`stage=tdd`, `sliceId=S-N`, `phase=red|green|refactor|refactor-deferred|doc`).
|
|
23
|
-
* When at least one slice carries any phase event, the linter
|
|
24
|
-
* auto-derives Watched-RED / Vertical Slice Cycle from the events
|
|
25
|
-
* and writes a rendered summary block between auto-render markers
|
|
26
|
-
* in `06-tdd.md`. Markdown table content is no longer required.
|
|
27
|
-
* 2. **Hand-authored markdown tables** (Watched-RED Proof + Vertical
|
|
28
|
-
* Slice Cycle) — used as a fallback when the events ledger has no
|
|
29
|
-
* slice phase rows for the active run.
|
|
30
|
-
* 3. **Sharded slice files** under `<artifacts-dir>/tdd-slices/S-*.md`.
|
|
31
|
-
* Per-slice prose lives there. The main `06-tdd.md` is auto-indexed
|
|
32
|
-
* via `## Slices Index`.
|
|
33
|
-
*/
|
|
34
|
-
export async function lintTddStage(ctx) {
|
|
35
|
-
const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter } = ctx;
|
|
36
|
-
void parsedFrontmatter;
|
|
37
|
-
const artifactsDir = path.dirname(absFile);
|
|
38
|
-
const planPath = path.join(artifactsDir, "05-plan.md");
|
|
39
|
-
let planRaw = "";
|
|
40
|
-
try {
|
|
41
|
-
planRaw = await fs.readFile(planPath, "utf8");
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
planRaw = "";
|
|
45
|
-
}
|
|
46
|
-
evaluateInvestigationTrace(ctx, "Watched-RED Proof");
|
|
47
|
-
const delegationLedger = await readDelegationLedger(ctx.projectRoot);
|
|
48
|
-
const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
|
|
49
|
-
const slicesByEvents = groupBySlice(activeRunEntries);
|
|
50
|
-
const eventsActive = slicesByEvents.size > 0;
|
|
51
|
-
const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
|
|
52
|
-
if (ironLawBody === null) {
|
|
53
|
-
findings.push({
|
|
54
|
-
section: "TDD Iron Law Acknowledgement",
|
|
55
|
-
required: true,
|
|
56
|
-
rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
|
|
57
|
-
found: false,
|
|
58
|
-
details: "No ## heading matching required section \"Iron Law Acknowledgement\"."
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
const ack = /acknowledged:\s*(yes|true|y)\b/iu.test(ironLawBody);
|
|
63
|
-
findings.push({
|
|
64
|
-
section: "TDD Iron Law Acknowledgement",
|
|
65
|
-
required: true,
|
|
66
|
-
rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
|
|
67
|
-
found: ack,
|
|
68
|
-
details: ack
|
|
69
|
-
? "TDD Iron Law acknowledged."
|
|
70
|
-
: "Iron Law Acknowledgement is missing explicit `Acknowledged: yes`."
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
|
|
74
|
-
if (eventsActive) {
|
|
75
|
-
const redResult = evaluateEventsWatchedRed(slicesByEvents);
|
|
76
|
-
findings.push({
|
|
77
|
-
section: "Watched-RED Proof Shape",
|
|
78
|
-
required: true,
|
|
79
|
-
rule: "Watched-RED Proof: when delegation-events.jsonl carries slice phase events, every slice with a phase=red row must include a non-empty evidenceRefs[] (test path, span ref, or pasted-output pointer) and a completedTs.",
|
|
80
|
-
found: redResult.ok,
|
|
81
|
-
details: redResult.details
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
else if (watchedRedBody === null) {
|
|
85
|
-
findings.push({
|
|
86
|
-
section: "Watched-RED Proof Shape",
|
|
87
|
-
required: true,
|
|
88
|
-
rule: "Watched-RED Proof must include at least one populated row, and each row must include an ISO timestamp showing when the test was observed failing.",
|
|
89
|
-
found: false,
|
|
90
|
-
details: "No ## heading matching required section \"Watched-RED Proof\"."
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
const rows = watchedRedBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
95
|
-
const dataRows = rows.length >= 3 ? rows.slice(2) : [];
|
|
96
|
-
const populatedRows = dataRows.filter((row) => row
|
|
97
|
-
.split("|")
|
|
98
|
-
.slice(1, -1)
|
|
99
|
-
.filter((_, idx) => idx !== 0)
|
|
100
|
-
.some((cell) => cell.trim().length > 0));
|
|
101
|
-
const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
|
|
102
|
-
const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
|
|
103
|
-
const hasPopulatedRows = populatedRows.length > 0;
|
|
104
|
-
const allRowsHaveIso = validProofRows.length === populatedRows.length;
|
|
105
|
-
findings.push({
|
|
106
|
-
section: "Watched-RED Proof Shape",
|
|
107
|
-
required: true,
|
|
108
|
-
rule: "Watched-RED Proof must include at least one populated row, and each row must include an ISO timestamp showing when the test was observed failing.",
|
|
109
|
-
found: hasPopulatedRows && allRowsHaveIso,
|
|
110
|
-
details: !hasPopulatedRows
|
|
111
|
-
? "Watched-RED Proof has no populated rows; add at least one slice row with observed RED evidence."
|
|
112
|
-
: allRowsHaveIso
|
|
113
|
-
? `All ${populatedRows.length} watched-RED proof row(s) include an ISO timestamp.`
|
|
114
|
-
: `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
if (eventsActive) {
|
|
118
|
-
const cycleResult = evaluateEventsSliceCycle(slicesByEvents);
|
|
119
|
-
findings.push({
|
|
120
|
-
section: "Vertical Slice Cycle Coverage",
|
|
121
|
-
required: true,
|
|
122
|
-
rule: "Vertical Slice Cycle: every slice with phase events must show RED before GREEN (completedTs monotonic), and a REFACTOR phase event (`refactor` with completedTs OR `refactor-deferred` with non-empty refactorRationale or evidenceRefs).",
|
|
123
|
-
found: cycleResult.ok,
|
|
124
|
-
details: cycleResult.details
|
|
125
|
-
});
|
|
126
|
-
for (const finding of cycleResult.findings) {
|
|
127
|
-
findings.push(finding);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
|
|
132
|
-
if (sliceCycleBody === null) {
|
|
133
|
-
findings.push({
|
|
134
|
-
section: "Vertical Slice Cycle Coverage",
|
|
135
|
-
required: true,
|
|
136
|
-
rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
|
|
137
|
-
found: false,
|
|
138
|
-
details: "No ## heading matching required section \"Vertical Slice Cycle\"."
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
const cycleResult = parseVerticalSliceCycle(sliceCycleBody);
|
|
143
|
-
findings.push({
|
|
144
|
-
section: "Vertical Slice Cycle Coverage",
|
|
145
|
-
required: true,
|
|
146
|
-
rule: "Vertical Slice Cycle must show RED -> GREEN -> REFACTOR monotonic progression per slice (refactor may be deferred with one-line rationale, e.g. `deferred because <reason>`).",
|
|
147
|
-
found: cycleResult.ok,
|
|
148
|
-
details: cycleResult.details
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// slice-builder owns DOC inline. For every slice with a phase=green
|
|
153
|
-
// row, require a matching phase=doc event whose evidenceRefs reference
|
|
154
|
-
// `<artifacts-dir>/tdd-slices/S-<id>.md`. Mandatory only on deep
|
|
155
|
-
// discoveryMode; advisory otherwise.
|
|
156
|
-
if (eventsActive) {
|
|
157
|
-
const docResult = evaluateSliceDocCoverage(slicesByEvents);
|
|
158
|
-
if (docResult.missing.length > 0) {
|
|
159
|
-
const required = discoveryMode === "deep";
|
|
160
|
-
findings.push({
|
|
161
|
-
section: "tdd_slice_doc_missing",
|
|
162
|
-
required,
|
|
163
|
-
rule: required
|
|
164
|
-
? "deep mode: every TDD slice with a phase=green event must also carry a slice-builder `phase=doc` event whose evidenceRefs reference `<artifacts-dir>/tdd-slices/S-<id>.md`."
|
|
165
|
-
: "lean/guided modes: the slice-builder `phase=doc` event is advisory; the doc step may be folded into the GREEN span. Required only for deep mode.",
|
|
166
|
-
found: false,
|
|
167
|
-
details: `Slices missing per-slice DOC coverage: ${docResult.missing.join(", ")}. ` +
|
|
168
|
-
(required
|
|
169
|
-
? "Have the slice-builder emit a `--phase doc` row referencing `tdd-slices/S-<id>.md` after GREEN."
|
|
170
|
-
: "Either emit a `--phase doc` row referencing `tdd-slices/S-<id>.md` or fold the doc write into GREEN.")
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
// slice-builder must own GREEN. For each slice with a phase=red row
|
|
175
|
-
// carrying non-empty evidenceRefs, require a matching phase=green event
|
|
176
|
-
// whose `agent === "slice-builder"`. Catches "controller wrote GREEN
|
|
177
|
-
// itself" backslides.
|
|
178
|
-
if (eventsActive) {
|
|
179
|
-
const implResult = evaluateSliceBuilderCoverage(slicesByEvents);
|
|
180
|
-
if (implResult.missing.length > 0) {
|
|
181
|
-
findings.push({
|
|
182
|
-
section: "tdd_slice_builder_missing",
|
|
183
|
-
required: true,
|
|
184
|
-
rule: "Every TDD slice that recorded a phase=red event with non-empty evidenceRefs must reach phase=green via `slice-builder`. Controller writing GREEN production code itself is forbidden.",
|
|
185
|
-
found: false,
|
|
186
|
-
details: `Slices missing slice-builder-owned GREEN coverage: ${implResult.missing.join(", ")}. Dispatch slice-builder --slice <id> --phase green --paths <comma-separated production paths>.`
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Per-slice RED-before-GREEN only (no global-red wave barrier in the linter).
|
|
191
|
-
if (eventsActive) {
|
|
192
|
-
const perSliceResult = evaluatePerSliceRedBeforeGreen(slicesByEvents);
|
|
193
|
-
if (!perSliceResult.ok) {
|
|
194
|
-
findings.push({
|
|
195
|
-
section: "tdd_slice_red_completed_before_green",
|
|
196
|
-
required: true,
|
|
197
|
-
rule: "Each slice's phase=green completedTs must be >= the same slice's last phase=red completedTs. Lanes run independently within a wave.",
|
|
198
|
-
found: false,
|
|
199
|
-
details: perSliceResult.details
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
const { events: jsonlEvents } = await readDelegationEvents(projectRoot);
|
|
204
|
-
const runEvents = jsonlEvents.filter((e) => e.runId === delegationLedger.runId);
|
|
205
|
-
if (eventsActive && planRaw.length > 0) {
|
|
206
|
-
const ignoredWave = await evaluateWavePlanDispatchIgnored({
|
|
207
|
-
artifactsDir,
|
|
208
|
-
planMarkdown: planRaw,
|
|
209
|
-
runEvents,
|
|
210
|
-
runId: delegationLedger.runId,
|
|
211
|
-
slices: slicesByEvents
|
|
212
|
-
});
|
|
213
|
-
if (ignoredWave) {
|
|
214
|
-
findings.push(ignoredWave);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
|
|
218
|
-
if (assertionBody !== null) {
|
|
219
|
-
const tableRows = assertionBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
220
|
-
const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
|
|
221
|
-
const ok = dataRows.length === 0 || dataRows.some((row) => row
|
|
222
|
-
.split("|")
|
|
223
|
-
.slice(1, -1)
|
|
224
|
-
.some((cell) => cell.trim().length > 0));
|
|
225
|
-
findings.push({
|
|
226
|
-
section: "Assertion Correctness Notes Shape",
|
|
227
|
-
required: true,
|
|
228
|
-
rule: "Assertion Correctness Notes must include at least one populated row when the slice has new assertions.",
|
|
229
|
-
found: ok,
|
|
230
|
-
details: ok
|
|
231
|
-
? "Assertion Correctness Notes is populated or absent (single-step slice)."
|
|
232
|
-
: "Assertion Correctness Notes table has no populated rows."
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
const testDiscoveryBody = sectionBodyByName(sections, "Test Discovery") ?? "";
|
|
236
|
-
const redEvidenceBody = sectionBodyByName(sections, "RED Evidence") ?? "";
|
|
237
|
-
const mockPreferenceScanBody = `${testDiscoveryBody}\n${redEvidenceBody}`;
|
|
238
|
-
const mockTokenRegex = /\b(jest\.mock|vi\.mock|sinon\.stub|mock\.patch|unittest\.mock|magicmock|spyon|tohavebeencalled)\b/iu;
|
|
239
|
-
if (mockTokenRegex.test(mockPreferenceScanBody)) {
|
|
240
|
-
const boundaryJustificationRegex = /\b(justified\s+by\s+boundary|boundary:\s*[A-Za-z0-9/_ -]*(network|fs|filesystem|time|clock|external)|network|filesystem|clock|external\s+service)\b/iu;
|
|
241
|
-
const hasBoundaryJustification = boundaryJustificationRegex.test(mockPreferenceScanBody);
|
|
242
|
-
const realPathRegex = /\b(?:src|lib|packages|apps)\/[A-Za-z0-9_./-]+\b/u;
|
|
243
|
-
const hasRealPathHint = realPathRegex.test(mockPreferenceScanBody);
|
|
244
|
-
findings.push({
|
|
245
|
-
section: "Mock Preference Heuristic",
|
|
246
|
-
required: false,
|
|
247
|
-
rule: "When mocks/spies appear in Test Discovery or RED Evidence, prefer Real > Fake > Stub > Mock. Mock-heavy slices need explicit boundary justification (network/fs/time/external).",
|
|
248
|
-
found: hasBoundaryJustification,
|
|
249
|
-
details: hasBoundaryJustification
|
|
250
|
-
? "Mock usage is explicitly justified by boundary constraints."
|
|
251
|
-
: hasRealPathHint
|
|
252
|
-
? "Mocks/spies detected while real implementation paths are listed; prefer Real > Fake > Stub > Mock unless a boundary justification is added."
|
|
253
|
-
: "Mocks/spies detected without boundary justification; add explicit trust-boundary rationale or replace with real/fake/stub coverage."
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
const completedSliceBuilders = activeRunEntries.filter((entry) => entry.agent === "slice-builder" && entry.status === "completed");
|
|
257
|
-
const fanOutDetected = completedSliceBuilders.length > 1;
|
|
258
|
-
if (fanOutDetected) {
|
|
259
|
-
const cohesionContractMarkdownPath = path.join(artifactsDir, "cohesion-contract.md");
|
|
260
|
-
const cohesionContractJsonPath = path.join(artifactsDir, "cohesion-contract.json");
|
|
261
|
-
let cohesionContractFound = true;
|
|
262
|
-
const cohesionErrors = [];
|
|
263
|
-
try {
|
|
264
|
-
const markdown = await fs.readFile(cohesionContractMarkdownPath, "utf8");
|
|
265
|
-
if (!/#\s*Cohesion Contract\b/u.test(markdown)) {
|
|
266
|
-
cohesionContractFound = false;
|
|
267
|
-
cohesionErrors.push("cohesion-contract.md exists but missing `# Cohesion Contract` heading.");
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
cohesionContractFound = false;
|
|
272
|
-
cohesionErrors.push("cohesion-contract.md is missing.");
|
|
273
|
-
}
|
|
274
|
-
try {
|
|
275
|
-
const jsonRaw = await fs.readFile(cohesionContractJsonPath, "utf8");
|
|
276
|
-
const parsed = JSON.parse(jsonRaw);
|
|
277
|
-
const objectLike = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
|
278
|
-
const parsedRecord = objectLike ? parsed : null;
|
|
279
|
-
const hasRequiredShape = parsedRecord !== null &&
|
|
280
|
-
Array.isArray(parsedRecord.sharedTypes) &&
|
|
281
|
-
Array.isArray(parsedRecord.touchpoints) &&
|
|
282
|
-
Array.isArray(parsedRecord.slices) &&
|
|
283
|
-
parsedRecord.status !== undefined &&
|
|
284
|
-
typeof parsedRecord.status === "object" &&
|
|
285
|
-
parsedRecord.status !== null;
|
|
286
|
-
if (!hasRequiredShape) {
|
|
287
|
-
cohesionContractFound = false;
|
|
288
|
-
cohesionErrors.push("cohesion-contract.json must parse and include `sharedTypes[]`, `touchpoints[]`, `slices[]`, and `status`.");
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
catch {
|
|
292
|
-
cohesionContractFound = false;
|
|
293
|
-
cohesionErrors.push("cohesion-contract.json is missing or invalid JSON.");
|
|
294
|
-
}
|
|
295
|
-
findings.push({
|
|
296
|
-
section: "tdd.cohesion_contract_missing",
|
|
297
|
-
required: true,
|
|
298
|
-
rule: "When the delegation ledger has >1 completed slice-builder rows for the active TDD run, require `.cclaw/artifacts/cohesion-contract.md` and a parseable `.cclaw/artifacts/cohesion-contract.json` sidecar.",
|
|
299
|
-
found: cohesionContractFound,
|
|
300
|
-
details: cohesionContractFound
|
|
301
|
-
? `Fan-out detected (${completedSliceBuilders.length} completed slice-builder rows); cohesion contract markdown+JSON sidecar are present and parseable.`
|
|
302
|
-
: `${cohesionErrors.join(" ")} Use \`cclaw-cli internal cohesion-contract --stub\` only as a scaffold; the gate expects real cohesion data for fan-out waves.`
|
|
303
|
-
});
|
|
304
|
-
const completedOverseerRows = activeRunEntries.filter((entry) => entry.agent === "integration-overseer" && entry.status === "completed");
|
|
305
|
-
const overseerStatusInEvidence = completedOverseerRows.some((entry) => {
|
|
306
|
-
const refs = Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs.join(" ") : "";
|
|
307
|
-
return /\b(?:PASS_WITH_GAPS|PASS)\b/u.test(refs);
|
|
308
|
-
});
|
|
309
|
-
const overseerStatusInArtifact = /\bintegration-overseer\b[\s\S]{0,200}\b(?:PASS_WITH_GAPS|PASS)\b/iu.test(raw);
|
|
310
|
-
const integrationOverseerFound = completedOverseerRows.length > 0 &&
|
|
311
|
-
(overseerStatusInEvidence || overseerStatusInArtifact);
|
|
312
|
-
const skippedAuditRowCount = await countIntegrationOverseerSkippedAudits(projectRoot, delegationLedger.runId);
|
|
313
|
-
const skippedAuditRowFound = skippedAuditRowCount > 0;
|
|
314
|
-
// Advisory: when fan-out is detected (2+ completed slice-builders) and
|
|
315
|
-
// no `integration-overseer` was dispatched at all (no scheduled or
|
|
316
|
-
// completed row for the active run), AND no
|
|
317
|
-
// `cclaw_integration_overseer_skipped` audit row exists, the controller
|
|
318
|
-
// should call `integrationCheckRequired()` and emit a
|
|
319
|
-
// `cclaw_integration_overseer_skipped` audit row so the decision stays
|
|
320
|
-
// traceable.
|
|
321
|
-
const overseerDispatched = activeRunEntries.some((entry) => entry.agent === "integration-overseer");
|
|
322
|
-
if (!overseerDispatched && !skippedAuditRowFound) {
|
|
323
|
-
findings.push({
|
|
324
|
-
section: "tdd_integration_overseer_skipped_audit_missing",
|
|
325
|
-
required: false,
|
|
326
|
-
rule: "When a wave with 2+ closed slices closes without any integration-overseer dispatch, the controller should call `integrationCheckRequired()` and emit a `cclaw_integration_overseer_skipped` audit row so the decision is traceable. Advisory — never blocks stage-complete.",
|
|
327
|
-
found: false,
|
|
328
|
-
details: `Fan-out detected (${completedSliceBuilders.length} completed slice-builder rows) but no integration-overseer dispatch row OR cclaw_integration_overseer_skipped audit row exists for active run. ` +
|
|
329
|
-
"Remediation: emit `node .cclaw/hooks/delegation-record.mjs --audit-kind=cclaw_integration_overseer_skipped --audit-reason=\"<reasons>\" --slice-ids=\"<S-1,S-2,...>\"` after wave closure."
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
findings.push({
|
|
333
|
-
section: "tdd.integration_overseer_missing",
|
|
334
|
-
required: true,
|
|
335
|
-
rule: "When fan-out is detected, require completed `integration-overseer` evidence with PASS or PASS_WITH_GAPS.",
|
|
336
|
-
found: integrationOverseerFound,
|
|
337
|
-
details: integrationOverseerFound
|
|
338
|
-
? "integration-overseer completion recorded with PASS/PASS_WITH_GAPS evidence."
|
|
339
|
-
: completedOverseerRows.length === 0
|
|
340
|
-
? "Fan-out detected but no completed integration-overseer delegation row exists for active run."
|
|
341
|
-
: "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
|
|
345
|
-
sectionBodyByName(sections, "Verification Status") ??
|
|
346
|
-
sectionBodyByName(sections, "Verification");
|
|
347
|
-
const ladderResult = evaluateVerificationLadder(verificationBody);
|
|
348
|
-
findings.push({
|
|
349
|
-
section: "tdd_verification_pending",
|
|
350
|
-
required: true,
|
|
351
|
-
rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
|
|
352
|
-
found: ladderResult.ok,
|
|
353
|
-
details: ladderResult.details
|
|
354
|
-
});
|
|
355
|
-
// Phase S — sharded slice files. Validate per-slice file presence
|
|
356
|
-
// and required headings. `tdd-slices/` is optional; missing folder
|
|
357
|
-
// simply means main-only mode (legacy fallback).
|
|
358
|
-
const slicesDir = path.join(artifactsDir, "tdd-slices");
|
|
359
|
-
const sliceFiles = await listSliceFiles(slicesDir);
|
|
360
|
-
const specAcceptanceIds = await readSpecAcceptanceCriteriaIds(projectRoot, ctx.track);
|
|
361
|
-
const specAcceptanceSet = new Set(specAcceptanceIds);
|
|
362
|
-
const slicesMissingCloses = [];
|
|
363
|
-
const slicesWithUnknownAcs = [];
|
|
364
|
-
let checkedSliceCards = 0;
|
|
365
|
-
for (const sliceFile of sliceFiles) {
|
|
366
|
-
const sliceId = sliceFile.sliceId;
|
|
367
|
-
const requiredForSlice = slicesByEvents.has(sliceId) &&
|
|
368
|
-
slicesByEvents.get(sliceId).some((entry) => entry.phase === "doc");
|
|
369
|
-
let content = "";
|
|
370
|
-
try {
|
|
371
|
-
content = await fs.readFile(sliceFile.absPath, "utf8");
|
|
372
|
-
}
|
|
373
|
-
catch {
|
|
374
|
-
content = "";
|
|
375
|
-
}
|
|
376
|
-
const issues = [];
|
|
377
|
-
if (!new RegExp(`^#\\s+Slice\\s+${escapeForRegex(sliceId)}\\b`, "mu").test(content) &&
|
|
378
|
-
!/^#\s+Slice\b/mu.test(content)) {
|
|
379
|
-
issues.push("missing `# Slice <id>` heading");
|
|
380
|
-
}
|
|
381
|
-
if (!/^##\s+Plan unit\b/imu.test(content)) {
|
|
382
|
-
issues.push("missing `## Plan unit` section");
|
|
383
|
-
}
|
|
384
|
-
if (!/^##\s+REFACTOR notes\b/imu.test(content)) {
|
|
385
|
-
issues.push("missing `## REFACTOR notes` section");
|
|
386
|
-
}
|
|
387
|
-
if (!/^##\s+Learnings\b/imu.test(content)) {
|
|
388
|
-
issues.push("missing `## Learnings` section");
|
|
389
|
-
}
|
|
390
|
-
checkedSliceCards += 1;
|
|
391
|
-
const closesIds = extractSliceCardClosedAcceptanceCriteria(content);
|
|
392
|
-
if (closesIds.length === 0) {
|
|
393
|
-
slicesMissingCloses.push(sliceId);
|
|
394
|
-
}
|
|
395
|
-
else if (specAcceptanceSet.size > 0) {
|
|
396
|
-
const unknown = closesIds.filter((acId) => !specAcceptanceSet.has(acId));
|
|
397
|
-
if (unknown.length > 0) {
|
|
398
|
-
slicesWithUnknownAcs.push(`${sliceId}: ${unknown.join(", ")}`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
findings.push({
|
|
402
|
-
section: `tdd_slice_file:${sliceId}`,
|
|
403
|
-
required: requiredForSlice,
|
|
404
|
-
rule: "Sharded slice file must include `# Slice <id>`, `## Plan unit`, `## REFACTOR notes`, and `## Learnings` headings.",
|
|
405
|
-
found: issues.length === 0,
|
|
406
|
-
details: issues.length === 0
|
|
407
|
-
? `tdd-slices/${path.basename(sliceFile.absPath)} has all required headings.`
|
|
408
|
-
: `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
const closesRequired = checkedSliceCards > 0;
|
|
412
|
-
const closesGatePassed = !closesRequired
|
|
413
|
-
? true
|
|
414
|
-
: slicesMissingCloses.length === 0 &&
|
|
415
|
-
slicesWithUnknownAcs.length === 0;
|
|
416
|
-
findings.push({
|
|
417
|
-
section: "tdd_slice_closes_ac",
|
|
418
|
-
required: true,
|
|
419
|
-
rule: "Every `tdd-slices/S-<id>.md` card must include `Closes: AC-N` links (comma-separated allowed) that reference real spec AC ids.",
|
|
420
|
-
found: closesGatePassed,
|
|
421
|
-
details: !closesRequired
|
|
422
|
-
? "No `tdd-slices/S-*.md` slice cards found yet; `Closes: AC-N` check is idle."
|
|
423
|
-
: slicesMissingCloses.length > 0
|
|
424
|
-
? `Slice card(s) missing \`Closes: AC-N\`: ${slicesMissingCloses.join(", ")}.`
|
|
425
|
-
: slicesWithUnknownAcs.length > 0
|
|
426
|
-
? `Slice card(s) reference unknown AC ids: ${slicesWithUnknownAcs.join(" | ")}.`
|
|
427
|
-
: specAcceptanceSet.size === 0
|
|
428
|
-
? `All ${checkedSliceCards} slice card(s) include Closes links; spec AC list unavailable for strict ID cross-check.`
|
|
429
|
-
: `All ${checkedSliceCards} slice card(s) include valid Closes links to spec AC ids.`
|
|
430
|
-
});
|
|
431
|
-
const orphanCheck = await evaluateSliceNoOrphanChanges(projectRoot, activeRunEntries);
|
|
432
|
-
findings.push({
|
|
433
|
-
section: "slice_no_orphan_changes",
|
|
434
|
-
required: true,
|
|
435
|
-
rule: "On slice phase=doc, there must be no staged/unstaged changes outside the slice `claimedPaths` (worktree root when present, otherwise project root).",
|
|
436
|
-
found: orphanCheck.ok,
|
|
437
|
-
details: orphanCheck.details
|
|
438
|
-
});
|
|
439
|
-
// Auto-render the slice summary inside `06-tdd.md` between markers.
|
|
440
|
-
// Idempotent — content outside the markers is preserved. Skipped
|
|
441
|
-
// entirely when there is nothing to render, so legacy artifacts (no
|
|
442
|
-
// phase events, no sharded files) stay byte-for-byte unchanged.
|
|
443
|
-
if (eventsActive || sliceFiles.length > 0) {
|
|
444
|
-
try {
|
|
445
|
-
await renderTddSliceSummary({
|
|
446
|
-
mainArtifactPath: absFile,
|
|
447
|
-
slicesByEvents,
|
|
448
|
-
sliceFiles,
|
|
449
|
-
renderSummary: eventsActive,
|
|
450
|
-
renderIndex: sliceFiles.length > 0
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
catch {
|
|
454
|
-
// best-effort render — never block the gate.
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* count `cclaw_integration_overseer_skipped` audit rows in
|
|
460
|
-
* `delegation-events.jsonl` for a given runId. The audit row is not a
|
|
461
|
-
* `DelegationEvent` (no agent/status), so `readDelegationEvents`
|
|
462
|
-
* filters it out; we re-scan the raw file with a narrow JSON match.
|
|
463
|
-
*
|
|
464
|
-
* Best-effort: missing file or parse errors return 0.
|
|
465
|
-
*/
|
|
466
|
-
async function countIntegrationOverseerSkippedAudits(projectRoot, runId) {
|
|
467
|
-
const filePath = path.join(projectRoot, ".cclaw/state/delegation-events.jsonl");
|
|
468
|
-
let raw = "";
|
|
469
|
-
try {
|
|
470
|
-
raw = await fs.readFile(filePath, "utf8");
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
return 0;
|
|
474
|
-
}
|
|
475
|
-
let count = 0;
|
|
476
|
-
for (const line of raw.split(/\r?\n/u)) {
|
|
477
|
-
const trimmed = line.trim();
|
|
478
|
-
if (trimmed.length === 0)
|
|
479
|
-
continue;
|
|
480
|
-
let parsed;
|
|
481
|
-
try {
|
|
482
|
-
parsed = JSON.parse(trimmed);
|
|
483
|
-
}
|
|
484
|
-
catch {
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
488
|
-
continue;
|
|
489
|
-
const obj = parsed;
|
|
490
|
-
if (obj.event !== "cclaw_integration_overseer_skipped")
|
|
491
|
-
continue;
|
|
492
|
-
if (typeof obj.runId === "string" && obj.runId !== runId)
|
|
493
|
-
continue;
|
|
494
|
-
count += 1;
|
|
495
|
-
}
|
|
496
|
-
return count;
|
|
497
|
-
}
|
|
498
|
-
async function listSliceFiles(slicesDir) {
|
|
499
|
-
let entries = [];
|
|
500
|
-
try {
|
|
501
|
-
entries = await fs.readdir(slicesDir);
|
|
502
|
-
}
|
|
503
|
-
catch {
|
|
504
|
-
return [];
|
|
505
|
-
}
|
|
506
|
-
const files = [];
|
|
507
|
-
for (const name of entries) {
|
|
508
|
-
const match = /^(S-[A-Za-z0-9._-]+)\.md$/u.exec(name);
|
|
509
|
-
if (!match)
|
|
510
|
-
continue;
|
|
511
|
-
files.push({ sliceId: match[1], absPath: path.join(slicesDir, name) });
|
|
512
|
-
}
|
|
513
|
-
files.sort((a, b) => compareSliceIds(a.sliceId, b.sliceId));
|
|
514
|
-
return files;
|
|
515
|
-
}
|
|
516
|
-
function escapeForRegex(value) {
|
|
517
|
-
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
518
|
-
}
|
|
519
|
-
function normalizePathLike(value) {
|
|
520
|
-
const slashes = value.replace(/\\/gu, "/");
|
|
521
|
-
const withoutDot = slashes.replace(/^\.\//u, "");
|
|
522
|
-
return withoutDot.replace(/\/+$/u, "");
|
|
523
|
-
}
|
|
524
|
-
function parsePorcelainPaths(raw) {
|
|
525
|
-
const out = [];
|
|
526
|
-
for (const line of raw.split(/\r?\n/gu)) {
|
|
527
|
-
const trimmed = line.trimEnd();
|
|
528
|
-
if (trimmed.length < 4)
|
|
529
|
-
continue;
|
|
530
|
-
const status = trimmed.slice(0, 2);
|
|
531
|
-
if (status === "??") {
|
|
532
|
-
const p = normalizePathLike(trimmed.slice(3).trim());
|
|
533
|
-
if (p.length > 0)
|
|
534
|
-
out.push(p);
|
|
535
|
-
continue;
|
|
536
|
-
}
|
|
537
|
-
let p = trimmed.slice(3).trim();
|
|
538
|
-
const renameIdx = p.indexOf(" -> ");
|
|
539
|
-
if (renameIdx >= 0) {
|
|
540
|
-
p = p.slice(renameIdx + 4);
|
|
541
|
-
}
|
|
542
|
-
p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
|
|
543
|
-
if (p.length > 0)
|
|
544
|
-
out.push(p);
|
|
545
|
-
}
|
|
546
|
-
return [...new Set(out)];
|
|
547
|
-
}
|
|
548
|
-
async function gitChangedPaths(cwd) {
|
|
549
|
-
const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], { cwd });
|
|
550
|
-
return parsePorcelainPaths(stdout);
|
|
551
|
-
}
|
|
552
|
-
function matchesClaimedPath(changedPath, claimedPaths) {
|
|
553
|
-
const changed = normalizePathLike(changedPath);
|
|
554
|
-
return claimedPaths.some((rawClaimed) => {
|
|
555
|
-
const claimed = normalizePathLike(rawClaimed);
|
|
556
|
-
if (claimed.length === 0)
|
|
557
|
-
return false;
|
|
558
|
-
if (changed === claimed)
|
|
559
|
-
return true;
|
|
560
|
-
return changed.startsWith(`${claimed}/`);
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
function extractSliceCardClosedAcceptanceCriteria(content) {
|
|
564
|
-
const ids = new Set();
|
|
565
|
-
for (const match of content.matchAll(/^\s*(?:[-*]\s*)?closes\s*:\s*(.+)$/gimu)) {
|
|
566
|
-
const tail = match[1] ?? "";
|
|
567
|
-
for (const id of extractAcceptanceCriterionIdsFromMarkdown(tail)) {
|
|
568
|
-
ids.add(id);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return [...ids];
|
|
572
|
-
}
|
|
573
|
-
async function readSpecAcceptanceCriteriaIds(projectRoot, track) {
|
|
574
|
-
const specArtifact = await resolveStageArtifactPath("spec", {
|
|
575
|
-
projectRoot,
|
|
576
|
-
track,
|
|
577
|
-
intent: "read"
|
|
578
|
-
});
|
|
579
|
-
if (!(await exists(specArtifact.absPath))) {
|
|
580
|
-
return [];
|
|
581
|
-
}
|
|
582
|
-
try {
|
|
583
|
-
const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
|
|
584
|
-
const specSections = extractH2Sections(specRaw);
|
|
585
|
-
const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
|
|
586
|
-
return extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
return [];
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
function resolveClaimedPathsForDocRow(row, allRows) {
|
|
593
|
-
const fromRow = Array.isArray(row.claimedPaths) ? row.claimedPaths : [];
|
|
594
|
-
if (fromRow.length > 0) {
|
|
595
|
-
return [...new Set(fromRow.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
|
|
596
|
-
}
|
|
597
|
-
const fromSpan = allRows
|
|
598
|
-
.filter((entry) => entry.spanId === row.spanId &&
|
|
599
|
-
Array.isArray(entry.claimedPaths) &&
|
|
600
|
-
entry.claimedPaths.length > 0)
|
|
601
|
-
.flatMap((entry) => entry.claimedPaths);
|
|
602
|
-
return [...new Set(fromSpan.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
|
|
603
|
-
}
|
|
604
|
-
async function resolveWorktreeCwdForDocRow(projectRoot, row, allRows) {
|
|
605
|
-
const candidates = [
|
|
606
|
-
typeof row.worktreePath === "string" ? row.worktreePath.trim() : "",
|
|
607
|
-
...allRows
|
|
608
|
-
.filter((entry) => entry.spanId === row.spanId)
|
|
609
|
-
.map((entry) => (typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : ""))
|
|
610
|
-
].filter((value) => value.length > 0);
|
|
611
|
-
for (const candidateRaw of candidates) {
|
|
612
|
-
const candidateAbs = path.isAbsolute(candidateRaw)
|
|
613
|
-
? candidateRaw
|
|
614
|
-
: path.join(projectRoot, candidateRaw);
|
|
615
|
-
if (await exists(candidateAbs)) {
|
|
616
|
-
return candidateAbs;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
return projectRoot;
|
|
620
|
-
}
|
|
621
|
-
export async function evaluateSliceNoOrphanChanges(projectRoot, rows) {
|
|
622
|
-
if (!(await exists(path.join(projectRoot, ".git")))) {
|
|
623
|
-
return {
|
|
624
|
-
ok: true,
|
|
625
|
-
details: "No .git directory detected; orphan-change check skipped."
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
const docRows = rows.filter((entry) => entry.stage === "tdd" &&
|
|
629
|
-
entry.agent === "slice-builder" &&
|
|
630
|
-
entry.status === "completed" &&
|
|
631
|
-
entry.phase === "doc");
|
|
632
|
-
if (docRows.length === 0) {
|
|
633
|
-
return {
|
|
634
|
-
ok: true,
|
|
635
|
-
details: "No completed phase=doc rows found for the active run."
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
const missingClaimedPaths = [];
|
|
639
|
-
const driftRows = [];
|
|
640
|
-
for (const row of docRows) {
|
|
641
|
-
const claimedPaths = resolveClaimedPathsForDocRow(row, rows);
|
|
642
|
-
const rowKey = `${row.sliceId ?? "unknown-slice"}@${row.spanId ?? "unknown-span"}`;
|
|
643
|
-
if (claimedPaths.length === 0) {
|
|
644
|
-
missingClaimedPaths.push(rowKey);
|
|
645
|
-
continue;
|
|
646
|
-
}
|
|
647
|
-
const cwd = await resolveWorktreeCwdForDocRow(projectRoot, row, rows);
|
|
648
|
-
const changedPaths = await gitChangedPaths(cwd);
|
|
649
|
-
const driftPaths = changedPaths.filter((changedPath) => !matchesClaimedPath(changedPath, claimedPaths));
|
|
650
|
-
if (driftPaths.length > 0) {
|
|
651
|
-
driftRows.push(`${rowKey}: ${driftPaths.join(", ")}`);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
if (missingClaimedPaths.length > 0 || driftRows.length > 0) {
|
|
655
|
-
const parts = [];
|
|
656
|
-
if (missingClaimedPaths.length > 0) {
|
|
657
|
-
parts.push(`doc row(s) missing claimedPaths: ${missingClaimedPaths.join(", ")}`);
|
|
658
|
-
}
|
|
659
|
-
if (driftRows.length > 0) {
|
|
660
|
-
parts.push(`orphan working-tree changes detected: ${driftRows.join(" | ")}`);
|
|
661
|
-
}
|
|
662
|
-
return { ok: false, details: parts.join(". ") };
|
|
663
|
-
}
|
|
664
|
-
return {
|
|
665
|
-
ok: true,
|
|
666
|
-
details: `Checked ${docRows.length} doc row(s); no orphan changes escaped claimedPaths.`
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
function groupBySlice(entries) {
|
|
670
|
-
const grouped = new Map();
|
|
671
|
-
for (const entry of entries) {
|
|
672
|
-
if (typeof entry.sliceId !== "string" || entry.sliceId.length === 0)
|
|
673
|
-
continue;
|
|
674
|
-
if (typeof entry.phase !== "string" || entry.phase.length === 0)
|
|
675
|
-
continue;
|
|
676
|
-
if (entry.status !== "completed")
|
|
677
|
-
continue;
|
|
678
|
-
const list = grouped.get(entry.sliceId) ?? [];
|
|
679
|
-
list.push(entry);
|
|
680
|
-
grouped.set(entry.sliceId, list);
|
|
681
|
-
}
|
|
682
|
-
return grouped;
|
|
683
|
-
}
|
|
684
|
-
/** Group completed phase rows for a slice by `spanId` (falls back to a single legacy bucket). */
|
|
685
|
-
function groupSliceRowsBySpanId(rows) {
|
|
686
|
-
const grouped = new Map();
|
|
687
|
-
for (const entry of rows) {
|
|
688
|
-
const spanKey = typeof entry.spanId === "string" && entry.spanId.length > 0 ? entry.spanId : "__missing-span__";
|
|
689
|
-
const list = grouped.get(spanKey) ?? [];
|
|
690
|
-
list.push(entry);
|
|
691
|
-
grouped.set(spanKey, list);
|
|
692
|
-
}
|
|
693
|
-
return grouped;
|
|
694
|
-
}
|
|
695
|
-
function maxPhaseTimestampForSpan(rows) {
|
|
696
|
-
let max = "";
|
|
697
|
-
for (const entry of rows) {
|
|
698
|
-
const ts = entry.completedTs ?? entry.endTs ?? entry.ts ?? "";
|
|
699
|
-
if (typeof ts === "string" && ts.length > 0 && ts > max)
|
|
700
|
-
max = ts;
|
|
701
|
-
}
|
|
702
|
-
return max;
|
|
703
|
-
}
|
|
704
|
-
/**
|
|
705
|
-
* Validate RED→GREEN→REFACTOR (incl. green `refactorOutcome`) monotonicity for one slice-builder span.
|
|
706
|
-
* `rows` must contain only entries for that span.
|
|
707
|
-
*/
|
|
708
|
-
function evaluateSingleSpanSliceCycle(sliceId, spanId, rows) {
|
|
709
|
-
const errors = [];
|
|
710
|
-
const findings = [];
|
|
711
|
-
const sec = (slug) => `${slug}:${sliceId}@${spanId}`;
|
|
712
|
-
const reds = rows.filter((entry) => entry.phase === "red");
|
|
713
|
-
const greens = rows.filter((entry) => entry.phase === "green");
|
|
714
|
-
const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
|
|
715
|
-
const redTs = pickEventTs(reds);
|
|
716
|
-
const greenTs = pickEventTs(greens);
|
|
717
|
-
if (reds.length === 0) {
|
|
718
|
-
errors.push(`${sliceId}: phase=red event missing.`);
|
|
719
|
-
findings.push({
|
|
720
|
-
section: sec("tdd_slice_red_missing"),
|
|
721
|
-
required: true,
|
|
722
|
-
rule: "Each TDD slice with phase events must include a `phase=red` row.",
|
|
723
|
-
found: false,
|
|
724
|
-
details: `${sliceId} (span ${spanId}): no phase=red event recorded for the active run.`
|
|
725
|
-
});
|
|
726
|
-
return { ok: false, errors, findings };
|
|
727
|
-
}
|
|
728
|
-
if (greens.length === 0) {
|
|
729
|
-
errors.push(`${sliceId}: phase=green event missing.`);
|
|
730
|
-
findings.push({
|
|
731
|
-
section: sec("tdd_slice_green_missing"),
|
|
732
|
-
required: true,
|
|
733
|
-
rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
|
|
734
|
-
found: false,
|
|
735
|
-
details: `${sliceId} (span ${spanId}): no phase=green event recorded; RED has no matching GREEN.`
|
|
736
|
-
});
|
|
737
|
-
return { ok: false, errors, findings };
|
|
738
|
-
}
|
|
739
|
-
if (greenTs && redTs && greenTs < redTs) {
|
|
740
|
-
errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
|
|
741
|
-
findings.push({
|
|
742
|
-
section: sec("tdd_slice_phase_order_invalid"),
|
|
743
|
-
required: true,
|
|
744
|
-
rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
|
|
745
|
-
found: false,
|
|
746
|
-
details: `${sliceId} (span ${spanId}): green at ${greenTs} precedes red at ${redTs}.`
|
|
747
|
-
});
|
|
748
|
-
return { ok: false, errors, findings };
|
|
749
|
-
}
|
|
750
|
-
const greenEvidenceRef = greens
|
|
751
|
-
.flatMap((entry) => (Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []))
|
|
752
|
-
.find((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
753
|
-
if (!greenEvidenceRef) {
|
|
754
|
-
errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
|
|
755
|
-
findings.push({
|
|
756
|
-
section: sec("tdd_slice_evidence_missing"),
|
|
757
|
-
required: true,
|
|
758
|
-
rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
|
|
759
|
-
found: false,
|
|
760
|
-
details: `${sliceId} (span ${spanId}): phase=green event missing evidenceRefs.`
|
|
761
|
-
});
|
|
762
|
-
return { ok: false, errors, findings };
|
|
763
|
-
}
|
|
764
|
-
const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
|
|
765
|
-
(entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
|
|
766
|
-
if (refactors.length === 0 && !greenWithOutcome) {
|
|
767
|
-
errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
|
|
768
|
-
findings.push({
|
|
769
|
-
section: sec("tdd_slice_refactor_missing"),
|
|
770
|
-
required: true,
|
|
771
|
-
rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
|
|
772
|
-
found: false,
|
|
773
|
-
details: `${sliceId} (span ${spanId}): no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
|
|
774
|
-
});
|
|
775
|
-
return { ok: false, errors, findings };
|
|
776
|
-
}
|
|
777
|
-
if (greenWithOutcome &&
|
|
778
|
-
greenWithOutcome.refactorOutcome?.mode === "deferred" &&
|
|
779
|
-
!greenWithOutcome.refactorOutcome.rationale &&
|
|
780
|
-
!(Array.isArray(greenWithOutcome.evidenceRefs) &&
|
|
781
|
-
greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
|
|
782
|
-
errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
|
|
783
|
-
findings.push({
|
|
784
|
-
section: sec("tdd_slice_refactor_missing"),
|
|
785
|
-
required: true,
|
|
786
|
-
rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
|
|
787
|
-
found: false,
|
|
788
|
-
details: `${sliceId} (span ${spanId}): phase=green refactorOutcome.mode=deferred recorded without rationale.`
|
|
789
|
-
});
|
|
790
|
-
return { ok: false, errors, findings };
|
|
791
|
-
}
|
|
792
|
-
const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
|
|
793
|
-
if (refactors.length > 0 &&
|
|
794
|
-
deferred &&
|
|
795
|
-
refactors.every((entry) => entry.phase === "refactor-deferred")) {
|
|
796
|
-
const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
|
|
797
|
-
const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
798
|
-
if (!hasRationale) {
|
|
799
|
-
errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
|
|
800
|
-
findings.push({
|
|
801
|
-
section: sec("tdd_slice_refactor_missing"),
|
|
802
|
-
required: true,
|
|
803
|
-
rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
|
|
804
|
-
found: false,
|
|
805
|
-
details: `${sliceId} (span ${spanId}): phase=refactor-deferred recorded without rationale evidenceRefs.`
|
|
806
|
-
});
|
|
807
|
-
return { ok: false, errors, findings };
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return { ok: true, errors: [], findings: [] };
|
|
811
|
-
}
|
|
812
|
-
export function evaluateEventsWatchedRed(slices) {
|
|
813
|
-
const errors = [];
|
|
814
|
-
let redCount = 0;
|
|
815
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
816
|
-
const reds = rows.filter((entry) => entry.phase === "red");
|
|
817
|
-
if (reds.length === 0)
|
|
818
|
-
continue;
|
|
819
|
-
redCount += 1;
|
|
820
|
-
const issues = [];
|
|
821
|
-
for (const red of reds) {
|
|
822
|
-
const ts = red.completedTs ?? red.endTs ?? red.ts ?? "";
|
|
823
|
-
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(ts)) {
|
|
824
|
-
issues.push("phase=red row missing ISO completedTs");
|
|
825
|
-
}
|
|
826
|
-
if (!Array.isArray(red.evidenceRefs) ||
|
|
827
|
-
red.evidenceRefs.filter((ref) => typeof ref === "string" && ref.trim().length > 0).length === 0) {
|
|
828
|
-
issues.push("phase=red row has empty evidenceRefs");
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
if (issues.length > 0) {
|
|
832
|
-
errors.push(`${sliceId}: ${issues.join(", ")}`);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
if (redCount === 0) {
|
|
836
|
-
return {
|
|
837
|
-
ok: false,
|
|
838
|
-
details: "Watched-RED Proof: events ledger has slice phase rows but none with phase=red. Dispatch slice-builder --slice <id> --phase red so RED is observable in delegation-events.jsonl."
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
if (errors.length > 0) {
|
|
842
|
-
return {
|
|
843
|
-
ok: false,
|
|
844
|
-
details: `Watched-RED slice events missing required fields: ${errors.join(" | ")}.`
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
return {
|
|
848
|
-
ok: true,
|
|
849
|
-
details: `${redCount} slice(s) carry phase=red events with ISO completedTs and evidenceRefs.`
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
export function evaluateEventsSliceCycle(slices) {
|
|
853
|
-
const errors = [];
|
|
854
|
-
const findings = [];
|
|
855
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
856
|
-
const bySpan = groupSliceRowsBySpanId(rows);
|
|
857
|
-
const spanOutcomes = [];
|
|
858
|
-
for (const [spanId, spanRows] of bySpan.entries()) {
|
|
859
|
-
const result = evaluateSingleSpanSliceCycle(sliceId, spanId, spanRows);
|
|
860
|
-
spanOutcomes.push({
|
|
861
|
-
spanId,
|
|
862
|
-
maxTs: maxPhaseTimestampForSpan(spanRows),
|
|
863
|
-
result
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
if (spanOutcomes.some((s) => s.result.ok)) {
|
|
867
|
-
continue;
|
|
868
|
-
}
|
|
869
|
-
spanOutcomes.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : 0));
|
|
870
|
-
const chosen = spanOutcomes[0];
|
|
871
|
-
errors.push(...chosen.result.errors);
|
|
872
|
-
findings.push(...chosen.result.findings);
|
|
873
|
-
}
|
|
874
|
-
if (errors.length > 0) {
|
|
875
|
-
return {
|
|
876
|
-
ok: false,
|
|
877
|
-
details: errors.join(" "),
|
|
878
|
-
findings
|
|
879
|
-
};
|
|
880
|
-
}
|
|
881
|
-
return {
|
|
882
|
-
ok: true,
|
|
883
|
-
details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted); at least one span per slice satisfies the cycle.`,
|
|
884
|
-
findings: []
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
export function evaluateSliceDocCoverage(slices) {
|
|
888
|
-
const missing = [];
|
|
889
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
890
|
-
const hasGreen = rows.some((entry) => entry.phase === "green");
|
|
891
|
-
if (!hasGreen)
|
|
892
|
-
continue;
|
|
893
|
-
const refsAcrossPhases = rows.flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []);
|
|
894
|
-
const hasSliceFileRef = refsAcrossPhases.some((ref) => typeof ref === "string" && /tdd-slices\/S-[^/]+\.md/u.test(ref));
|
|
895
|
-
if (!hasSliceFileRef) {
|
|
896
|
-
missing.push(sliceId);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
return { missing };
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* `slice-builder` must own GREEN. For each slice that recorded a phase=red
|
|
903
|
-
* event with non-empty evidenceRefs, require a phase=green whose agent is
|
|
904
|
-
* `slice-builder`.
|
|
905
|
-
*/
|
|
906
|
-
export function evaluateSliceBuilderCoverage(slices) {
|
|
907
|
-
const missing = [];
|
|
908
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
909
|
-
const reds = rows.filter((entry) => entry.phase === "red");
|
|
910
|
-
if (reds.length === 0)
|
|
911
|
-
continue;
|
|
912
|
-
const hasRedEvidence = reds.some((red) => {
|
|
913
|
-
const refs = Array.isArray(red.evidenceRefs) ? red.evidenceRefs : [];
|
|
914
|
-
return refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
915
|
-
});
|
|
916
|
-
if (!hasRedEvidence)
|
|
917
|
-
continue;
|
|
918
|
-
const greens = rows.filter((entry) => entry.phase === "green");
|
|
919
|
-
const ownedByBuilder = greens.some((entry) => entry.agent === "slice-builder");
|
|
920
|
-
if (!ownedByBuilder) {
|
|
921
|
-
missing.push(sliceId);
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
return { missing };
|
|
925
|
-
}
|
|
926
|
-
function sliceRefactorTerminal(sliceId, slices) {
|
|
927
|
-
const rows = slices.get(sliceId);
|
|
928
|
-
if (!rows)
|
|
929
|
-
return false;
|
|
930
|
-
return rows.some((e) => e.agent === "slice-builder" &&
|
|
931
|
-
(e.phase === "refactor" || e.phase === "refactor-deferred") &&
|
|
932
|
-
(e.status === "completed" || e.status === "failed"));
|
|
933
|
-
}
|
|
934
|
-
/**
|
|
935
|
-
* Detect single-slice dispatch when the merged wave plan requires parallel
|
|
936
|
-
* ready slice-builder fan-out.
|
|
937
|
-
*/
|
|
938
|
-
export async function evaluateWavePlanDispatchIgnored(params) {
|
|
939
|
-
let merged;
|
|
940
|
-
try {
|
|
941
|
-
merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(params.planMarkdown), await parseWavePlanDirectory(params.artifactsDir));
|
|
942
|
-
}
|
|
943
|
-
catch {
|
|
944
|
-
return null;
|
|
945
|
-
}
|
|
946
|
-
if (merged.length === 0)
|
|
947
|
-
return null;
|
|
948
|
-
let pool;
|
|
949
|
-
try {
|
|
950
|
-
pool = await loadTddReadySlicePool(params.planMarkdown, params.artifactsDir, {
|
|
951
|
-
legacyParallelDefaultSerial: false
|
|
952
|
-
});
|
|
953
|
-
}
|
|
954
|
-
catch {
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
if (pool.length === 0)
|
|
958
|
-
return null;
|
|
959
|
-
const completedUnitIds = new Set();
|
|
960
|
-
for (const u of pool) {
|
|
961
|
-
if (sliceRefactorTerminal(u.sliceId, params.slices)) {
|
|
962
|
-
completedUnitIds.add(u.unitId);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
const scoped = params.runEvents.filter((e) => e.runId === params.runId);
|
|
966
|
-
const tail = scoped.slice(-20);
|
|
967
|
-
const builderInTail = new Set();
|
|
968
|
-
for (const e of tail) {
|
|
969
|
-
if (e.agent === "slice-builder" &&
|
|
970
|
-
typeof e.sliceId === "string" &&
|
|
971
|
-
e.sliceId.length > 0) {
|
|
972
|
-
builderInTail.add(e.sliceId);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
if (builderInTail.size !== 1)
|
|
976
|
-
return null;
|
|
977
|
-
for (const wave of merged) {
|
|
978
|
-
const waveSliceSet = new Set(wave.members.map((m) => m.sliceId));
|
|
979
|
-
const wavePool = pool.filter((u) => waveSliceSet.has(u.sliceId));
|
|
980
|
-
if (wavePool.length < 2)
|
|
981
|
-
continue;
|
|
982
|
-
const waveIncomplete = wave.members.some((m) => !sliceRefactorTerminal(m.sliceId, params.slices));
|
|
983
|
-
if (!waveIncomplete)
|
|
984
|
-
continue;
|
|
985
|
-
const ready = selectReadySlices(wavePool, {
|
|
986
|
-
cap: Math.max(32, wavePool.length),
|
|
987
|
-
completedUnitIds,
|
|
988
|
-
activePathHolders: []
|
|
989
|
-
});
|
|
990
|
-
if (ready.length < 2)
|
|
991
|
-
continue;
|
|
992
|
-
const only = [...builderInTail][0];
|
|
993
|
-
const missed = ready.map((r) => r.sliceId).filter((s) => s !== only);
|
|
994
|
-
if (missed.length === 0)
|
|
995
|
-
continue;
|
|
996
|
-
return {
|
|
997
|
-
section: "tdd_wave_plan_ignored",
|
|
998
|
-
required: true,
|
|
999
|
-
rule: "When the Parallel Execution Plan (or wave-plans/) defines an open wave with two or more ready parallelizable units/slices, the controller must honor the parallel-builders topology instead of serializing to one slice only.",
|
|
1000
|
-
found: false,
|
|
1001
|
-
details: `Wave ${wave.waveId}: scheduler-ready members ${ready.map((r) => r.sliceId).join(", ")}; last 20 delegation events show slice workers only for ${only}. Missed parallel dispatch: ${missed.join(", ")}. Remediation: load \`05-plan.md\` (Parallel Execution Plan) and \`wave-plans/\` before routing, honor \`nextDispatch.topology=parallel-builders\`, then dispatch the routed ready builders in one controller message.`
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
return null;
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* Global RED checkpoint enforcement (`global-red` mode).
|
|
1008
|
-
*
|
|
1009
|
-
* The wave protocol requires ALL Phase A REDs to land before ANY Phase B
|
|
1010
|
-
* GREEN starts. The rule is enforced on a per-wave basis, where a wave is
|
|
1011
|
-
* defined by the managed `## Parallel Execution Plan` block in
|
|
1012
|
-
* `05-plan.md` and/or `<artifacts-dir>/wave-plans/wave-NN.md` files. When
|
|
1013
|
-
* no wave manifest exists, the linter falls back to a conservative
|
|
1014
|
-
* implicit detection: a wave is a contiguous run of `phase=red` events
|
|
1015
|
-
* with no other-phase events between them; the rule fires only when the
|
|
1016
|
-
* implicit wave has 2+ members.
|
|
1017
|
-
*
|
|
1018
|
-
* Default mode is `per-slice` (see `evaluatePerSliceRedBeforeGreen`);
|
|
1019
|
-
* this checkpoint applies when a project explicitly opts into
|
|
1020
|
-
* `global-red`. Exported under both `evaluateGlobalRedCheckpoint`
|
|
1021
|
-
* (canonical name) and `evaluateRedCheckpoint` (back-compat alias for
|
|
1022
|
-
* existing tests/consumers).
|
|
1023
|
-
*
|
|
1024
|
-
* @param waveMembers Optional explicit wave manifest. Map key is wave
|
|
1025
|
-
* name (e.g. `"W-01"`); value is the set of slice ids in that wave.
|
|
1026
|
-
*/
|
|
1027
|
-
export function evaluateGlobalRedCheckpoint(slices, waveMembers = null) {
|
|
1028
|
-
const events = [];
|
|
1029
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
1030
|
-
for (const entry of rows) {
|
|
1031
|
-
const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
|
|
1032
|
-
if (typeof ts !== "string" || ts.length === 0)
|
|
1033
|
-
continue;
|
|
1034
|
-
if (typeof entry.phase !== "string")
|
|
1035
|
-
continue;
|
|
1036
|
-
events.push({ sliceId, phase: entry.phase, ts });
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
events.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
1040
|
-
// Build the canonical wave list. Explicit manifest wins; otherwise
|
|
1041
|
-
// derive implicit waves from contiguous red event blocks.
|
|
1042
|
-
const waves = [];
|
|
1043
|
-
if (waveMembers && waveMembers.size > 0) {
|
|
1044
|
-
for (const [name, members] of waveMembers.entries()) {
|
|
1045
|
-
if (members.size === 0)
|
|
1046
|
-
continue;
|
|
1047
|
-
waves.push({ name, members });
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
else {
|
|
1051
|
-
let current = null;
|
|
1052
|
-
let waveIdx = 0;
|
|
1053
|
-
for (const evt of events) {
|
|
1054
|
-
if (evt.phase === "red") {
|
|
1055
|
-
if (current === null)
|
|
1056
|
-
current = new Set();
|
|
1057
|
-
current.add(evt.sliceId);
|
|
1058
|
-
}
|
|
1059
|
-
else if (current !== null) {
|
|
1060
|
-
if (current.size >= 2) {
|
|
1061
|
-
waveIdx += 1;
|
|
1062
|
-
waves.push({ name: `implicit-${waveIdx}`, members: current });
|
|
1063
|
-
}
|
|
1064
|
-
current = null;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
if (current !== null && current.size >= 2) {
|
|
1068
|
-
waveIdx += 1;
|
|
1069
|
-
waves.push({ name: `implicit-${waveIdx}`, members: current });
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
if (waves.length === 0) {
|
|
1073
|
-
return {
|
|
1074
|
-
ok: true,
|
|
1075
|
-
details: "RED checkpoint inactive: no wave manifest detected and no implicit wave (2+ contiguous reds) found."
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
const violations = [];
|
|
1079
|
-
for (const wave of waves) {
|
|
1080
|
-
const memberReds = events.filter((e) => e.phase === "red" && wave.members.has(e.sliceId));
|
|
1081
|
-
const memberGreens = events.filter((e) => e.phase === "green" && wave.members.has(e.sliceId));
|
|
1082
|
-
if (memberReds.length === 0 || memberGreens.length === 0)
|
|
1083
|
-
continue;
|
|
1084
|
-
const lastRedTs = memberReds.reduce((acc, e) => (e.ts > acc ? e.ts : acc), memberReds[0].ts);
|
|
1085
|
-
for (const g of memberGreens) {
|
|
1086
|
-
if (g.ts < lastRedTs) {
|
|
1087
|
-
violations.push(`${wave.name}: ${g.sliceId} phase=green at ${g.ts} precedes wave's last phase=red completedTs at ${lastRedTs}`);
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
if (violations.length === 0) {
|
|
1092
|
-
return {
|
|
1093
|
-
ok: true,
|
|
1094
|
-
details: `RED checkpoint holds across ${waves.length} wave(s): all phase=green events follow the last phase=red of their wave.`
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
return {
|
|
1098
|
-
ok: false,
|
|
1099
|
-
details: `RED checkpoint violation: ${violations.join("; ")}. ` +
|
|
1100
|
-
"When using the global wave barrier, dispatch ALL slice-builder --phase red calls in one message, verify every phase=red event lands with non-empty evidenceRefs, and only then dispatch the GREEN/REFACTOR/DOC fan-out."
|
|
1101
|
-
};
|
|
1102
|
-
}
|
|
1103
|
-
/**
|
|
1104
|
-
* Back-compat alias for `evaluateGlobalRedCheckpoint`. The default mode
|
|
1105
|
-
* uses `evaluatePerSliceRedBeforeGreen` instead.
|
|
1106
|
-
*/
|
|
1107
|
-
export const evaluateRedCheckpoint = evaluateGlobalRedCheckpoint;
|
|
1108
|
-
/**
|
|
1109
|
-
* Per-slice RED-before-GREEN enforcement (default mode).
|
|
1110
|
-
*
|
|
1111
|
-
* For each slice with both phase=red and phase=green completed events,
|
|
1112
|
-
* fail if any green completedTs precedes the slice's last red completedTs.
|
|
1113
|
-
* No global wave barrier — different slices may freely interleave their
|
|
1114
|
-
* RED/GREEN/REFACTOR phases.
|
|
1115
|
-
*/
|
|
1116
|
-
export function evaluatePerSliceRedBeforeGreen(slices) {
|
|
1117
|
-
const violations = [];
|
|
1118
|
-
for (const [sliceId, rows] of slices.entries()) {
|
|
1119
|
-
const reds = rows.filter((entry) => entry.phase === "red");
|
|
1120
|
-
const greens = rows.filter((entry) => entry.phase === "green");
|
|
1121
|
-
if (reds.length === 0 || greens.length === 0)
|
|
1122
|
-
continue;
|
|
1123
|
-
const redTs = reds
|
|
1124
|
-
.map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
|
|
1125
|
-
.filter((ts) => ts.length > 0)
|
|
1126
|
-
.sort();
|
|
1127
|
-
const greenTs = greens
|
|
1128
|
-
.map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
|
|
1129
|
-
.filter((ts) => ts.length > 0)
|
|
1130
|
-
.sort();
|
|
1131
|
-
if (redTs.length === 0 || greenTs.length === 0)
|
|
1132
|
-
continue;
|
|
1133
|
-
const lastRed = redTs[redTs.length - 1];
|
|
1134
|
-
const earliestGreen = greenTs[0];
|
|
1135
|
-
if (earliestGreen < lastRed) {
|
|
1136
|
-
violations.push(`${sliceId}: phase=green completedTs (${earliestGreen}) precedes the slice's last phase=red completedTs (${lastRed})`);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
if (violations.length === 0) {
|
|
1140
|
-
return {
|
|
1141
|
-
ok: true,
|
|
1142
|
-
details: `Per-slice RED-before-GREEN holds: ${slices.size} slice(s) checked.`
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
return {
|
|
1146
|
-
ok: false,
|
|
1147
|
-
details: `Per-slice RED-before-GREEN violation: ${violations.join("; ")}. ` +
|
|
1148
|
-
"Stream-style TDD requires each slice's RED to land before its own GREEN, but cross-lane interleaving is allowed."
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
function pickEventTs(rows) {
|
|
1152
|
-
for (const entry of rows) {
|
|
1153
|
-
const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
|
|
1154
|
-
if (typeof ts === "string" && ts.length > 0)
|
|
1155
|
-
return ts;
|
|
1156
|
-
}
|
|
1157
|
-
return undefined;
|
|
1158
|
-
}
|
|
1159
|
-
export function parseVerticalSliceCycle(body) {
|
|
1160
|
-
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
1161
|
-
if (tableLines.length < 3) {
|
|
1162
|
-
return {
|
|
1163
|
-
ok: false,
|
|
1164
|
-
details: "Vertical Slice Cycle table must have a header, separator, and at least one slice row."
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
const headerCells = splitMarkdownRow(tableLines[0]).map((cell) => cell.toLowerCase());
|
|
1168
|
-
const findIdx = (token) => headerCells.findIndex((cell) => cell.includes(token));
|
|
1169
|
-
const sliceIdx = findIdx("slice");
|
|
1170
|
-
const redIdx = findIdx("red");
|
|
1171
|
-
const greenIdx = findIdx("green");
|
|
1172
|
-
const refactorIdx = findIdx("refactor");
|
|
1173
|
-
if (sliceIdx < 0 || redIdx < 0 || greenIdx < 0 || refactorIdx < 0) {
|
|
1174
|
-
return {
|
|
1175
|
-
ok: false,
|
|
1176
|
-
details: "Vertical Slice Cycle header must include Slice, RED, GREEN, and REFACTOR columns."
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
const dataRows = tableLines.slice(2);
|
|
1180
|
-
const populated = dataRows.filter((row) => splitMarkdownRow(row).some((cell) => cell.length > 0));
|
|
1181
|
-
if (populated.length === 0) {
|
|
1182
|
-
return {
|
|
1183
|
-
ok: false,
|
|
1184
|
-
details: "Vertical Slice Cycle has no populated slice rows."
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1187
|
-
const errors = [];
|
|
1188
|
-
for (const row of populated) {
|
|
1189
|
-
const cells = splitMarkdownRow(row);
|
|
1190
|
-
const slice = cells[sliceIdx] ?? "";
|
|
1191
|
-
const red = cells[redIdx] ?? "";
|
|
1192
|
-
const green = cells[greenIdx] ?? "";
|
|
1193
|
-
const refactor = cells[refactorIdx] ?? "";
|
|
1194
|
-
const label = slice.length > 0 ? slice : `row ${populated.indexOf(row) + 1}`;
|
|
1195
|
-
const redTs = parseTimestampCell(red);
|
|
1196
|
-
const greenTs = parseTimestampCell(green);
|
|
1197
|
-
if (red.length === 0) {
|
|
1198
|
-
errors.push(`${label}: RED ts is empty.`);
|
|
1199
|
-
continue;
|
|
1200
|
-
}
|
|
1201
|
-
if (green.length === 0) {
|
|
1202
|
-
errors.push(`${label}: GREEN ts is empty.`);
|
|
1203
|
-
continue;
|
|
1204
|
-
}
|
|
1205
|
-
if (redTs === null) {
|
|
1206
|
-
errors.push(`${label}: RED ts \`${red}\` is not an ISO timestamp.`);
|
|
1207
|
-
continue;
|
|
1208
|
-
}
|
|
1209
|
-
if (greenTs === null) {
|
|
1210
|
-
errors.push(`${label}: GREEN ts \`${green}\` is not an ISO timestamp.`);
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
if (greenTs < redTs) {
|
|
1214
|
-
errors.push(`${label}: GREEN (${green}) precedes RED (${red}) — order must be monotonic.`);
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
if (refactor.length === 0) {
|
|
1218
|
-
errors.push(`${label}: REFACTOR cell is empty; provide a timestamp or \`deferred because <reason>\`.`);
|
|
1219
|
-
continue;
|
|
1220
|
-
}
|
|
1221
|
-
if (isDeferredOrNotNeeded(refactor)) {
|
|
1222
|
-
const rationale = extractDeferRationale(refactor);
|
|
1223
|
-
if (rationale.length === 0) {
|
|
1224
|
-
errors.push(`${label}: REFACTOR marked deferred/not-needed but rationale is missing — use \`deferred because <reason>\` or \`not needed because <reason>\`.`);
|
|
1225
|
-
}
|
|
1226
|
-
continue;
|
|
1227
|
-
}
|
|
1228
|
-
const refactorTs = parseTimestampCell(refactor);
|
|
1229
|
-
if (refactorTs === null) {
|
|
1230
|
-
errors.push(`${label}: REFACTOR cell \`${refactor}\` is not an ISO timestamp and not marked deferred/not-needed with rationale.`);
|
|
1231
|
-
continue;
|
|
1232
|
-
}
|
|
1233
|
-
if (refactorTs < greenTs) {
|
|
1234
|
-
errors.push(`${label}: REFACTOR (${refactor}) precedes GREEN (${green}) — order must be monotonic.`);
|
|
1235
|
-
continue;
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
if (errors.length > 0) {
|
|
1239
|
-
return { ok: false, details: errors.join(" ") };
|
|
1240
|
-
}
|
|
1241
|
-
return {
|
|
1242
|
-
ok: true,
|
|
1243
|
-
details: `${populated.length} slice row(s) show monotonic RED -> GREEN -> REFACTOR (deferred-with-rationale accepted).`
|
|
1244
|
-
};
|
|
1245
|
-
}
|
|
1246
|
-
function splitMarkdownRow(line) {
|
|
1247
|
-
const trimmed = line.trim();
|
|
1248
|
-
if (!trimmed.startsWith("|"))
|
|
1249
|
-
return [];
|
|
1250
|
-
const inner = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
|
|
1251
|
-
return inner.split("|").map((cell) => cell.trim());
|
|
1252
|
-
}
|
|
1253
|
-
function parseTimestampCell(cell) {
|
|
1254
|
-
const trimmed = cell.replace(/^[`*_\s]+|[`*_\s]+$/gu, "");
|
|
1255
|
-
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(trimmed))
|
|
1256
|
-
return null;
|
|
1257
|
-
const t = Date.parse(trimmed);
|
|
1258
|
-
return Number.isFinite(t) ? t : null;
|
|
1259
|
-
}
|
|
1260
|
-
function isDeferredOrNotNeeded(cell) {
|
|
1261
|
-
return /\b(deferred|not[\s-]?needed|n\/?a|skipped)\b/iu.test(cell);
|
|
1262
|
-
}
|
|
1263
|
-
function extractDeferRationale(cell) {
|
|
1264
|
-
const cleaned = cell.replace(/`/gu, "").trim();
|
|
1265
|
-
const match = /(?:deferred|not[\s-]?needed|skipped)\s+(?:because|since|due to|—|-)\s*(.+)/iu.exec(cleaned);
|
|
1266
|
-
if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
|
|
1267
|
-
return match[1].trim();
|
|
1268
|
-
}
|
|
1269
|
-
const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
|
|
1270
|
-
return fallback;
|
|
1271
|
-
}
|
|
1272
|
-
export function evaluateVerificationLadder(body) {
|
|
1273
|
-
if (body === null) {
|
|
1274
|
-
return {
|
|
1275
|
-
ok: true,
|
|
1276
|
-
details: "No Verification Ladder section present; rule advisory."
|
|
1277
|
-
};
|
|
1278
|
-
}
|
|
1279
|
-
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
1280
|
-
if (tableLines.length < 3) {
|
|
1281
|
-
return {
|
|
1282
|
-
ok: true,
|
|
1283
|
-
details: "Verification Ladder section has no table rows; rule advisory."
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
const dataRows = tableLines.slice(2);
|
|
1287
|
-
const pendingRows = [];
|
|
1288
|
-
for (const row of dataRows) {
|
|
1289
|
-
const cells = splitMarkdownRow(row);
|
|
1290
|
-
if (cells.length === 0)
|
|
1291
|
-
continue;
|
|
1292
|
-
if (cells.every((cell) => cell.length === 0))
|
|
1293
|
-
continue;
|
|
1294
|
-
const cellsLower = cells.map((cell) => cell.toLowerCase().replace(/`/gu, "").trim());
|
|
1295
|
-
const hasPending = cellsLower.some((cell) => /\bpending\b/u.test(cell));
|
|
1296
|
-
if (hasPending) {
|
|
1297
|
-
const label = cells[0] !== undefined && cells[0].length > 0
|
|
1298
|
-
? cells[0]
|
|
1299
|
-
: `row ${dataRows.indexOf(row) + 1}`;
|
|
1300
|
-
pendingRows.push(label);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
if (pendingRows.length === 0) {
|
|
1304
|
-
return {
|
|
1305
|
-
ok: true,
|
|
1306
|
-
details: "Verification Ladder has no rows still marked `pending`."
|
|
1307
|
-
};
|
|
1308
|
-
}
|
|
1309
|
-
return {
|
|
1310
|
-
ok: false,
|
|
1311
|
-
details: `Verification Ladder has ${pendingRows.length} row(s) still marked \`pending\`: ${pendingRows.join(", ")}. ` +
|
|
1312
|
-
"Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
export async function renderTddSliceSummary(input) {
|
|
1316
|
-
let raw;
|
|
1317
|
-
try {
|
|
1318
|
-
raw = await fs.readFile(input.mainArtifactPath, "utf8");
|
|
1319
|
-
}
|
|
1320
|
-
catch {
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
1323
|
-
let next = raw;
|
|
1324
|
-
if (input.renderSummary !== false) {
|
|
1325
|
-
const summaryBlock = renderSliceSummaryBlock(input.slicesByEvents);
|
|
1326
|
-
next = upsertAutoBlock(next, SLICE_SUMMARY_START, SLICE_SUMMARY_END, summaryBlock);
|
|
1327
|
-
}
|
|
1328
|
-
if (input.renderIndex !== false) {
|
|
1329
|
-
const indexBlock = renderSlicesIndexBlock(input.sliceFiles);
|
|
1330
|
-
next = upsertAutoBlock(next, SLICES_INDEX_START, SLICES_INDEX_END, indexBlock);
|
|
1331
|
-
}
|
|
1332
|
-
if (next !== raw) {
|
|
1333
|
-
try {
|
|
1334
|
-
await fs.writeFile(input.mainArtifactPath, next, "utf8");
|
|
1335
|
-
}
|
|
1336
|
-
catch {
|
|
1337
|
-
// best-effort render
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
function renderSliceSummaryBlock(slices) {
|
|
1342
|
-
if (slices.size === 0) {
|
|
1343
|
-
return "## Vertical Slice Cycle\n\n_No slice phase events recorded for the active run._";
|
|
1344
|
-
}
|
|
1345
|
-
const sortedIds = [...slices.keys()].sort();
|
|
1346
|
-
const rows = [];
|
|
1347
|
-
rows.push("## Vertical Slice Cycle");
|
|
1348
|
-
rows.push("");
|
|
1349
|
-
rows.push("| Slice | RED ts | GREEN ts | REFACTOR | Implementer | Test refs |");
|
|
1350
|
-
rows.push("|---|---|---|---|---|---|");
|
|
1351
|
-
for (const sliceId of sortedIds) {
|
|
1352
|
-
const events = slices.get(sliceId);
|
|
1353
|
-
const red = events.find((entry) => entry.phase === "red");
|
|
1354
|
-
const green = events.find((entry) => entry.phase === "green");
|
|
1355
|
-
const refactor = events.find((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
|
|
1356
|
-
const redTs = red?.completedTs ?? red?.endTs ?? red?.ts ?? "";
|
|
1357
|
-
const greenTs = green?.completedTs ?? green?.endTs ?? green?.ts ?? "";
|
|
1358
|
-
let refactorCell;
|
|
1359
|
-
if (!refactor) {
|
|
1360
|
-
refactorCell = "";
|
|
1361
|
-
}
|
|
1362
|
-
else if (refactor.phase === "refactor-deferred") {
|
|
1363
|
-
const refs = Array.isArray(refactor.evidenceRefs) ? refactor.evidenceRefs : [];
|
|
1364
|
-
const rationale = refs.find((ref) => typeof ref === "string" && ref.trim().length > 0) ?? "";
|
|
1365
|
-
refactorCell = `deferred because ${rationale}`.trim();
|
|
1366
|
-
}
|
|
1367
|
-
else {
|
|
1368
|
-
refactorCell = refactor.completedTs ?? refactor.ts ?? "";
|
|
1369
|
-
}
|
|
1370
|
-
const implementer = green?.agent ?? red?.agent ?? "";
|
|
1371
|
-
const refsList = green?.evidenceRefs ?? red?.evidenceRefs ?? [];
|
|
1372
|
-
const testRefs = Array.isArray(refsList) ? refsList.join(", ") : "";
|
|
1373
|
-
rows.push(`| ${sliceId} | ${redTs} | ${greenTs} | ${escapeTableCell(refactorCell)} | ${implementer} | ${escapeTableCell(testRefs)} |`);
|
|
1374
|
-
}
|
|
1375
|
-
return rows.join("\n");
|
|
1376
|
-
}
|
|
1377
|
-
function renderSlicesIndexBlock(sliceFiles) {
|
|
1378
|
-
if (sliceFiles.length === 0) {
|
|
1379
|
-
return "## Slices Index\n\n_No `tdd-slices/S-*.md` files present._";
|
|
1380
|
-
}
|
|
1381
|
-
const lines = [];
|
|
1382
|
-
lines.push("## Slices Index");
|
|
1383
|
-
lines.push("");
|
|
1384
|
-
for (const file of sliceFiles) {
|
|
1385
|
-
lines.push(`- [${file.sliceId}](tdd-slices/${path.basename(file.absPath)})`);
|
|
1386
|
-
}
|
|
1387
|
-
return lines.join("\n");
|
|
1388
|
-
}
|
|
1389
|
-
function escapeTableCell(value) {
|
|
1390
|
-
return value.replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ");
|
|
1391
|
-
}
|
|
1392
|
-
function upsertAutoBlock(raw, startMarker, endMarker, bodyContent) {
|
|
1393
|
-
const startIdx = raw.indexOf(startMarker);
|
|
1394
|
-
const endIdx = raw.indexOf(endMarker);
|
|
1395
|
-
const replacement = `${startMarker}\n${bodyContent}\n${endMarker}`;
|
|
1396
|
-
if (startIdx >= 0 && endIdx > startIdx) {
|
|
1397
|
-
const before = raw.slice(0, startIdx);
|
|
1398
|
-
const after = raw.slice(endIdx + endMarker.length);
|
|
1399
|
-
return `${before}${replacement}${after}`;
|
|
1400
|
-
}
|
|
1401
|
-
// append to end
|
|
1402
|
-
const sep = raw.endsWith("\n") ? "" : "\n";
|
|
1403
|
-
return `${raw}${sep}\n${replacement}\n`;
|
|
1404
|
-
}
|