cclaw-cli 7.0.6 → 7.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/dist/config.d.ts +4 -1
- package/dist/config.js +44 -5
- package/dist/content/core-agents.js +1 -0
- package/dist/content/hooks.d.ts +1 -0
- package/dist/content/hooks.js +116 -0
- package/dist/content/stages/tdd.js +4 -4
- package/dist/install.js +3 -1
- package/dist/internal/advance-stage.js +6 -2
- package/dist/internal/slice-commit.d.ts +7 -0
- package/dist/internal/slice-commit.js +296 -0
- package/dist/tdd-verification-evidence.js +101 -10
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack } from "./types.js";
|
|
1
|
+
import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode } from "./types.js";
|
|
2
|
+
export declare const TDD_COMMIT_MODES: readonly ["managed-per-slice", "agent-required", "checkpoint-only", "off"];
|
|
3
|
+
export declare const DEFAULT_TDD_COMMIT_MODE: TddCommitMode;
|
|
2
4
|
export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
|
|
3
5
|
export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
|
|
4
6
|
export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
|
|
@@ -16,6 +18,7 @@ export declare class InvalidConfigError extends Error {
|
|
|
16
18
|
}
|
|
17
19
|
export declare function configPath(projectRoot: string): string;
|
|
18
20
|
export declare function createDefaultConfig(harnesses?: HarnessId[], _defaultTrack?: FlowTrack): CclawConfig;
|
|
21
|
+
export declare function resolveTddCommitMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddCommitMode;
|
|
19
22
|
export declare function detectLanguageRulePacks(_projectRoot: string): Promise<LanguageRulePack[]>;
|
|
20
23
|
export declare function readConfig(projectRoot: string, _options?: ReadConfigOptions): Promise<CclawConfig>;
|
|
21
24
|
export interface WriteConfigOptions {
|
package/dist/config.js
CHANGED
|
@@ -6,8 +6,16 @@ import { exists, writeFileSafe } from "./fs-utils.js";
|
|
|
6
6
|
import { HARNESS_IDS } from "./types.js";
|
|
7
7
|
const CONFIG_PATH = `${RUNTIME_ROOT}/config.yaml`;
|
|
8
8
|
const HARNESS_ID_SET = new Set(HARNESS_IDS);
|
|
9
|
-
const ALLOWED_CONFIG_KEYS = new Set(["version", "flowVersion", "harnesses"]);
|
|
9
|
+
const ALLOWED_CONFIG_KEYS = new Set(["version", "flowVersion", "harnesses", "tdd"]);
|
|
10
10
|
const SUPPORTED_HARNESSES_TEXT = HARNESS_IDS.join(", ");
|
|
11
|
+
export const TDD_COMMIT_MODES = [
|
|
12
|
+
"managed-per-slice",
|
|
13
|
+
"agent-required",
|
|
14
|
+
"checkpoint-only",
|
|
15
|
+
"off"
|
|
16
|
+
];
|
|
17
|
+
const TDD_COMMIT_MODE_SET = new Set(TDD_COMMIT_MODES);
|
|
18
|
+
export const DEFAULT_TDD_COMMIT_MODE = "managed-per-slice";
|
|
11
19
|
// Kept for runtime modules that use these defaults directly.
|
|
12
20
|
export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
|
|
13
21
|
"**/*.test.*",
|
|
@@ -30,7 +38,9 @@ export class InvalidConfigError extends Error {
|
|
|
30
38
|
function configFixExample() {
|
|
31
39
|
return `harnesses:
|
|
32
40
|
- claude
|
|
33
|
-
- cursor
|
|
41
|
+
- cursor
|
|
42
|
+
tdd:
|
|
43
|
+
commitMode: managed-per-slice`;
|
|
34
44
|
}
|
|
35
45
|
function configValidationError(configFilePath, reason) {
|
|
36
46
|
return new InvalidConfigError(`Invalid cclaw config at ${configFilePath}: ${reason}\n` +
|
|
@@ -48,9 +58,19 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, _defaultTrack
|
|
|
48
58
|
return {
|
|
49
59
|
version: CCLAW_VERSION,
|
|
50
60
|
flowVersion: FLOW_VERSION,
|
|
51
|
-
harnesses: [...new Set(harnesses)]
|
|
61
|
+
harnesses: [...new Set(harnesses)],
|
|
62
|
+
tdd: {
|
|
63
|
+
commitMode: DEFAULT_TDD_COMMIT_MODE
|
|
64
|
+
}
|
|
52
65
|
};
|
|
53
66
|
}
|
|
67
|
+
export function resolveTddCommitMode(config) {
|
|
68
|
+
const raw = config?.tdd?.commitMode;
|
|
69
|
+
if (typeof raw === "string" && TDD_COMMIT_MODE_SET.has(raw)) {
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
return DEFAULT_TDD_COMMIT_MODE;
|
|
73
|
+
}
|
|
54
74
|
function assertOnlySupportedKeys(parsed, fullPath) {
|
|
55
75
|
const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
|
|
56
76
|
if (unknownKeys.length === 0)
|
|
@@ -84,6 +104,10 @@ export async function readConfig(projectRoot, _options = {}) {
|
|
|
84
104
|
!Array.isArray(parsed.harnesses)) {
|
|
85
105
|
throw configValidationError(fullPath, `"harnesses" must be an array`);
|
|
86
106
|
}
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "tdd") &&
|
|
108
|
+
!isRecord(parsed.tdd)) {
|
|
109
|
+
throw configValidationError(fullPath, `"tdd" must be an object when provided`);
|
|
110
|
+
}
|
|
87
111
|
const rawHarnesses = Array.isArray(parsed.harnesses) ? parsed.harnesses : DEFAULT_HARNESSES;
|
|
88
112
|
const normalizedHarnesses = [];
|
|
89
113
|
for (const harness of rawHarnesses) {
|
|
@@ -103,17 +127,32 @@ export async function readConfig(projectRoot, _options = {}) {
|
|
|
103
127
|
const flowVersion = typeof parsed.flowVersion === "string" && parsed.flowVersion.trim().length > 0
|
|
104
128
|
? parsed.flowVersion
|
|
105
129
|
: FLOW_VERSION;
|
|
130
|
+
const parsedTdd = isRecord(parsed.tdd) ? parsed.tdd : {};
|
|
131
|
+
const rawCommitMode = parsedTdd.commitMode;
|
|
132
|
+
if (rawCommitMode !== undefined &&
|
|
133
|
+
(typeof rawCommitMode !== "string" || !TDD_COMMIT_MODE_SET.has(rawCommitMode))) {
|
|
134
|
+
throw configValidationError(fullPath, `"tdd.commitMode" must be one of: ${TDD_COMMIT_MODES.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
const commitMode = typeof rawCommitMode === "string"
|
|
137
|
+
? rawCommitMode
|
|
138
|
+
: DEFAULT_TDD_COMMIT_MODE;
|
|
106
139
|
return {
|
|
107
140
|
version,
|
|
108
141
|
flowVersion,
|
|
109
|
-
harnesses: normalizedHarnesses
|
|
142
|
+
harnesses: normalizedHarnesses,
|
|
143
|
+
tdd: {
|
|
144
|
+
commitMode
|
|
145
|
+
}
|
|
110
146
|
};
|
|
111
147
|
}
|
|
112
148
|
export async function writeConfig(projectRoot, config, _options = {}) {
|
|
113
149
|
const serialisable = {
|
|
114
150
|
version: config.version,
|
|
115
151
|
flowVersion: config.flowVersion,
|
|
116
|
-
harnesses: config.harnesses
|
|
152
|
+
harnesses: config.harnesses,
|
|
153
|
+
tdd: {
|
|
154
|
+
commitMode: resolveTddCommitMode(config)
|
|
155
|
+
}
|
|
117
156
|
};
|
|
118
157
|
await writeFileSafe(configPath(projectRoot), stringify(serialisable));
|
|
119
158
|
}
|
|
@@ -156,6 +156,7 @@ export function sliceBuilderProtocol() {
|
|
|
156
156
|
"### Invariants",
|
|
157
157
|
"- Produce failing RED evidence (or cite the delegated RED artifact) **before** production edits.",
|
|
158
158
|
"- Stay inside the slice contract: `claimedPaths`, acceptance mapping, and forbidden-change lists from the parent.",
|
|
159
|
+
"- When `tdd.commitMode=managed-per-slice`, do **not** hand-edit git state for slice files (no manual `git add/commit` on claimed paths). Let `.cclaw/hooks/slice-commit.mjs` own per-slice commits.",
|
|
159
160
|
"- After GREEN, refactor inline **or** record deferred refactor via the same `--refactor-outcome` mechanics the controller specifies.",
|
|
160
161
|
"- Own the prose slice summary at `<artifacts-dir>/tdd-slices/S-<id>.md` yourself.",
|
|
161
162
|
"",
|
package/dist/content/hooks.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare function startFlowScript(): string;
|
|
|
2
2
|
export declare function cancelRunScript(): string;
|
|
3
3
|
export declare function stageCompleteScript(): string;
|
|
4
4
|
export declare function delegationRecordScript(): string;
|
|
5
|
+
export declare function sliceCommitScript(): string;
|
|
5
6
|
export declare function runHookCmdScript(): string;
|
|
6
7
|
export { claudeHooksJsonWithObservation as claudeHooksJson } from "./observe.js";
|
|
7
8
|
export { cursorHooksJsonWithObservation as cursorHooksJson } from "./observe.js";
|
package/dist/content/hooks.js
CHANGED
|
@@ -200,6 +200,7 @@ export function stageCompleteScript() {
|
|
|
200
200
|
export function delegationRecordScript() {
|
|
201
201
|
return `#!/usr/bin/env node
|
|
202
202
|
import { createHash } from "node:crypto";
|
|
203
|
+
import { spawn } from "node:child_process";
|
|
203
204
|
import fs from "node:fs/promises";
|
|
204
205
|
import path from "node:path";
|
|
205
206
|
import process from "node:process";
|
|
@@ -1189,6 +1190,101 @@ async function runRepair(args, json) {
|
|
|
1189
1190
|
}
|
|
1190
1191
|
}
|
|
1191
1192
|
|
|
1193
|
+
async function runSliceCommitIfNeeded(root, row, runId) {
|
|
1194
|
+
if (
|
|
1195
|
+
row.stage !== "tdd" ||
|
|
1196
|
+
row.agent !== "slice-builder" ||
|
|
1197
|
+
row.status !== "completed" ||
|
|
1198
|
+
row.phase !== "doc"
|
|
1199
|
+
) {
|
|
1200
|
+
return { ok: true, skipped: true };
|
|
1201
|
+
}
|
|
1202
|
+
const sliceId = typeof row.sliceId === "string" ? row.sliceId.trim() : "";
|
|
1203
|
+
const spanId = typeof row.spanId === "string" ? row.spanId.trim() : "";
|
|
1204
|
+
if (sliceId.length === 0 || spanId.length === 0) {
|
|
1205
|
+
return { ok: true, skipped: true };
|
|
1206
|
+
}
|
|
1207
|
+
const helperPath = path.join(root, RUNTIME_ROOT, "hooks", "slice-commit.mjs");
|
|
1208
|
+
if (!(await exists(helperPath))) {
|
|
1209
|
+
return { ok: true, skipped: true };
|
|
1210
|
+
}
|
|
1211
|
+
const helperArgs = [
|
|
1212
|
+
helperPath,
|
|
1213
|
+
"--json",
|
|
1214
|
+
"--quiet",
|
|
1215
|
+
"--slice=" + sliceId,
|
|
1216
|
+
"--span-id=" + spanId,
|
|
1217
|
+
"--run-id=" + runId
|
|
1218
|
+
];
|
|
1219
|
+
if (typeof row.taskId === "string" && row.taskId.trim().length > 0) {
|
|
1220
|
+
helperArgs.push("--task-id=" + row.taskId.trim());
|
|
1221
|
+
}
|
|
1222
|
+
if (Array.isArray(row.claimedPaths) && row.claimedPaths.length > 0) {
|
|
1223
|
+
helperArgs.push("--claimed-paths=" + row.claimedPaths.join(","));
|
|
1224
|
+
}
|
|
1225
|
+
if (Array.isArray(row.evidenceRefs) && row.evidenceRefs.length > 0) {
|
|
1226
|
+
const title = String(row.evidenceRefs[0] || "").trim();
|
|
1227
|
+
if (title.length > 0) {
|
|
1228
|
+
helperArgs.push("--title=" + title.slice(0, 120));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return await new Promise((resolve) => {
|
|
1233
|
+
const child = spawn(process.execPath, helperArgs, {
|
|
1234
|
+
cwd: root,
|
|
1235
|
+
env: process.env,
|
|
1236
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1237
|
+
});
|
|
1238
|
+
let out = "";
|
|
1239
|
+
let err = "";
|
|
1240
|
+
child.stdout.on("data", (chunk) => {
|
|
1241
|
+
out += String(chunk ?? "");
|
|
1242
|
+
});
|
|
1243
|
+
child.stderr.on("data", (chunk) => {
|
|
1244
|
+
err += String(chunk ?? "");
|
|
1245
|
+
});
|
|
1246
|
+
child.on("error", (error) => {
|
|
1247
|
+
resolve({
|
|
1248
|
+
ok: false,
|
|
1249
|
+
errorCode: "slice_commit_failed",
|
|
1250
|
+
details: {
|
|
1251
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
child.on("close", (code) => {
|
|
1256
|
+
let payload = null;
|
|
1257
|
+
const trimmed = out.trim();
|
|
1258
|
+
if (trimmed.length > 0) {
|
|
1259
|
+
try {
|
|
1260
|
+
payload = JSON.parse(trimmed);
|
|
1261
|
+
} catch {
|
|
1262
|
+
payload = null;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (code === 0) {
|
|
1266
|
+
resolve({ ok: true, payload });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const payloadCode =
|
|
1270
|
+
payload && typeof payload === "object" && typeof payload.errorCode === "string"
|
|
1271
|
+
? payload.errorCode
|
|
1272
|
+
: "slice_commit_failed";
|
|
1273
|
+
resolve({
|
|
1274
|
+
ok: false,
|
|
1275
|
+
errorCode: payloadCode,
|
|
1276
|
+
details:
|
|
1277
|
+
payload && typeof payload === "object"
|
|
1278
|
+
? payload
|
|
1279
|
+
: {
|
|
1280
|
+
stderr: err.trim(),
|
|
1281
|
+
stdout: out.trim()
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1192
1288
|
async function main() {
|
|
1193
1289
|
const args = parseArgs(process.argv.slice(2));
|
|
1194
1290
|
const json = args.json !== undefined;
|
|
@@ -1573,6 +1669,23 @@ async function main() {
|
|
|
1573
1669
|
}
|
|
1574
1670
|
}
|
|
1575
1671
|
|
|
1672
|
+
const sliceCommitResult = await runSliceCommitIfNeeded(root, clean, runId);
|
|
1673
|
+
if (!sliceCommitResult.ok) {
|
|
1674
|
+
emitErrorJson(
|
|
1675
|
+
sliceCommitResult.errorCode || "slice_commit_failed",
|
|
1676
|
+
sliceCommitResult.details || {},
|
|
1677
|
+
json
|
|
1678
|
+
);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (
|
|
1682
|
+
sliceCommitResult.payload &&
|
|
1683
|
+
typeof sliceCommitResult.payload === "object" &&
|
|
1684
|
+
typeof sliceCommitResult.payload.commitSha === "string"
|
|
1685
|
+
) {
|
|
1686
|
+
event.sliceCommitSha = sliceCommitResult.payload.commitSha;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1576
1689
|
await persistEntry(root, runId, clean, event);
|
|
1577
1690
|
|
|
1578
1691
|
process.stdout.write(JSON.stringify({ ok: true, event }, null, 2) + "\\n");
|
|
@@ -1581,6 +1694,9 @@ async function main() {
|
|
|
1581
1694
|
void main();
|
|
1582
1695
|
`;
|
|
1583
1696
|
}
|
|
1697
|
+
export function sliceCommitScript() {
|
|
1698
|
+
return internalHelperScript("slice-commit", "slice-commit", "Usage: node " + RUNTIME_ROOT + "/hooks/slice-commit.mjs --slice=<S-N> --span-id=<span-id> [--task-id=<T-id>] [--title=<text>] [--run-id=<run-id>] [--claimed-paths=<path1,path2,...>] [--claimed-path=<path> ...] [--json] [--quiet]");
|
|
1699
|
+
}
|
|
1584
1700
|
export function runHookCmdScript() {
|
|
1585
1701
|
return `: << 'CMDBLOCK'
|
|
1586
1702
|
@echo off
|
|
@@ -52,7 +52,7 @@ export const TDD = {
|
|
|
52
52
|
"Controller never writes production code or per-slice prose — the delegated worker does. Record routing decisions; cite `wave-status` before redundant slice questions.",
|
|
53
53
|
"Discover existing tests and commands before RED; run a system-wide impact check (callbacks, state, interfaces, contracts) before GREEN.",
|
|
54
54
|
"RED must fail for the right reason; capture logs. GREEN must run the full relevant suite, not a narrow subset.",
|
|
55
|
-
"Before calling a slice done, run verification-before-completion (command + PASS/FAIL + commit
|
|
55
|
+
"Before calling a slice done, run verification-before-completion (command + PASS/FAIL + durable commit evidence: managed-per-slice git commits when `.git` is present, or explicit no-VCS attestation + hash).",
|
|
56
56
|
"Integration-overseer must complete with PASS/PASS_WITH_GAPS when fan-out closes a wave unless the controller emits `cclaw_integration_overseer_skipped` for a documented heuristic skip.",
|
|
57
57
|
"Investigation discipline + behavior anchor in this skill govern evidence: cite commands and paths, not pasted source dumps.",
|
|
58
58
|
],
|
|
@@ -72,7 +72,7 @@ export const TDD = {
|
|
|
72
72
|
{ id: "tdd_red_test_written", description: "Failing tests exist before implementation changes." },
|
|
73
73
|
{ id: "tdd_green_full_suite", description: "Full relevant suite passes in GREEN state." },
|
|
74
74
|
{ id: "tdd_refactor_completed", description: "Refactor pass completed with behavior preservation verified." },
|
|
75
|
-
{ id: "tdd_verified_before_complete", description: "Fresh verification evidence includes test command
|
|
75
|
+
{ id: "tdd_verified_before_complete", description: "Fresh verification evidence includes test command + explicit pass/fail status; when `tdd.commitMode=managed-per-slice` and `.git` exists, closed slices must be backed by real git commits, otherwise provide explicit no-VCS attestation + hash." },
|
|
76
76
|
{ id: "tdd_iron_law_acknowledged", description: "Iron Law acknowledgement is explicit (`Acknowledged: yes`) before implementation proceeds." },
|
|
77
77
|
{ id: "tdd_watched_red_observed", description: "Watched-RED Proof records at least one observed failing test with ISO timestamp evidence." },
|
|
78
78
|
{ id: "tdd_slice_cycle_complete", description: "Vertical Slice Cycle records RED, GREEN, and REFACTOR phases per active slice." },
|
|
@@ -89,7 +89,7 @@ export const TDD = {
|
|
|
89
89
|
"REFACTOR coverage: separate `phase=refactor|refactor-deferred` rows or `refactorOutcome` folded into GREEN as the hook documents.",
|
|
90
90
|
"`tdd-slices/S-<id>.md` kept current with the builder span; phase events remain the ground truth for lint auto-render blocks.",
|
|
91
91
|
"`event: slice-completed` umbrella rows tie RED/GREEN timestamps to the builder once that writer runs on the repo.",
|
|
92
|
-
"Fresh verification (command + PASS/FAIL + commit
|
|
92
|
+
"Fresh verification (command + PASS/FAIL + managed-per-slice commit proof from git log when `.git` exists, or no-VCS reason + hash); Iron Law acknowledgement; acceptance mapping + traceability IDs.",
|
|
93
93
|
],
|
|
94
94
|
inputs: ["approved plan slice", "spec acceptance criterion", "test harness configuration", "coding standards and constraints"],
|
|
95
95
|
requiredContext: ["plan artifact", "spec artifact", "existing test patterns", "affected contracts and state boundaries"],
|
|
@@ -135,7 +135,7 @@ export const TDD = {
|
|
|
135
135
|
{ section: "REFACTOR Notes", required: true, validationRule: "What changed, why, behavior preservation confirmed." },
|
|
136
136
|
{ section: "Traceability", required: true, validationRule: "Plan task ID and spec criterion linked." },
|
|
137
137
|
{ section: "Iron Law Acknowledgement", required: true, validationRule: "Must include `Acknowledged: yes` and list exceptions (or `None`)." },
|
|
138
|
-
{ section: "Verification Ladder", required: true, validationRule: "Per-slice verification tier (static, command, behavioral, human) with evidence captured for the highest tier reached this turn. Must include command + PASS/FAIL
|
|
138
|
+
{ section: "Verification Ladder", required: true, validationRule: "Per-slice verification tier (static, command, behavioral, human) with evidence captured for the highest tier reached this turn. Must include command + PASS/FAIL and durable commit evidence: managed-per-slice git commit proof when VCS is present, or explicit no-vcs reason plus content/artifact hash/config override." },
|
|
139
139
|
{ section: "TDD Blocker Taxonomy", required: false, validationRule: "When blocked, classify as NO_SOURCE_CONTEXT, NO_TEST_SURFACE, NO_IMPLEMENTABLE_SLICE, RED_NOT_EXPRESSIBLE, or NO_VCS_MODE; include blockedBecause, missingInputs, recommendedRoute, nextCommand, and resumeCriteria." }
|
|
140
140
|
]
|
|
141
141
|
},
|
package/dist/install.js
CHANGED
|
@@ -13,7 +13,7 @@ import { cancelCommandContract, cancelCommandSkillMarkdown } from "./content/can
|
|
|
13
13
|
import { subagentDrivenDevSkill, parallelAgentsSkill } from "./content/subagents.js";
|
|
14
14
|
import { sessionHooksSkillMarkdown } from "./content/session-hooks.js";
|
|
15
15
|
import { ironLawsSkillMarkdown } from "./content/iron-laws.js";
|
|
16
|
-
import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
|
|
16
|
+
import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, sliceCommitScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
|
|
17
17
|
import { nodeHookRuntimeScript } from "./content/node-hooks.js";
|
|
18
18
|
import { META_SKILL_NAME, usingCclawSkillMarkdown } from "./content/meta-skill.js";
|
|
19
19
|
import { ARTIFACT_TEMPLATES, CURSOR_GUIDELINES_RULE_MDC, CURSOR_WORKFLOW_RULE_MDC, RULEBOOK_MARKDOWN, buildRulesJson } from "./content/templates.js";
|
|
@@ -692,6 +692,7 @@ async function writeHooks(projectRoot, config) {
|
|
|
692
692
|
await writeFileSafe(path.join(hooksDir, "run-hook.mjs"), bundledHookRuntime ?? nodeHookRuntimeScript(hookRuntimeOptions));
|
|
693
693
|
await writeFileSafe(path.join(hooksDir, "run-hook.cmd"), runHookCmdScript());
|
|
694
694
|
await writeFileSafe(path.join(hooksDir, "delegation-record.mjs"), delegationRecordScript());
|
|
695
|
+
await writeFileSafe(path.join(hooksDir, "slice-commit.mjs"), sliceCommitScript());
|
|
695
696
|
const opencodePluginSource = opencodePluginJs();
|
|
696
697
|
await writeFileSafe(path.join(hooksDir, "opencode-plugin.mjs"), opencodePluginSource);
|
|
697
698
|
try {
|
|
@@ -701,6 +702,7 @@ async function writeHooks(projectRoot, config) {
|
|
|
701
702
|
"run-hook.mjs",
|
|
702
703
|
"run-hook.cmd",
|
|
703
704
|
"delegation-record.mjs",
|
|
705
|
+
"slice-commit.mjs",
|
|
704
706
|
"opencode-plugin.mjs",
|
|
705
707
|
"cancel-run.mjs"
|
|
706
708
|
]) {
|
|
@@ -18,6 +18,7 @@ import { DelegationTimestampError, DispatchCapError, DispatchClaimedPathProtecte
|
|
|
18
18
|
import { parsePlanSplitWavesArgs, runPlanSplitWaves } from "./plan-split-waves.js";
|
|
19
19
|
import { runWaveStatusCommand } from "./wave-status.js";
|
|
20
20
|
import { runCohesionContractCommand } from "./cohesion-contract-stub.js";
|
|
21
|
+
import { runSliceCommitCommand } from "./slice-commit.js";
|
|
21
22
|
/**
|
|
22
23
|
* Subcommands that mutate or consult flow-state.json via the CLI runtime.
|
|
23
24
|
* They all require the sha256 sidecar to match before continuing so a
|
|
@@ -35,7 +36,7 @@ const GUARD_ENFORCED_SUBCOMMANDS = new Set([
|
|
|
35
36
|
export async function runInternalCommand(projectRoot, argv, io) {
|
|
36
37
|
const [subcommand, ...tokens] = argv;
|
|
37
38
|
if (!subcommand) {
|
|
38
|
-
io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n");
|
|
39
|
+
io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n");
|
|
39
40
|
return 1;
|
|
40
41
|
}
|
|
41
42
|
try {
|
|
@@ -81,6 +82,9 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
81
82
|
if (subcommand === "hook") {
|
|
82
83
|
return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
|
|
83
84
|
}
|
|
85
|
+
if (subcommand === "slice-commit") {
|
|
86
|
+
return await runSliceCommitCommand(projectRoot, tokens, io);
|
|
87
|
+
}
|
|
84
88
|
if (subcommand === "flow-state-repair") {
|
|
85
89
|
return await runFlowStateRepair(projectRoot, parseFlowStateRepairArgs(tokens), io);
|
|
86
90
|
}
|
|
@@ -96,7 +100,7 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
96
100
|
if (subcommand === "cohesion-contract") {
|
|
97
101
|
return await runCohesionContractCommand(projectRoot, tokens, io);
|
|
98
102
|
}
|
|
99
|
-
io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n`);
|
|
103
|
+
io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n`);
|
|
100
104
|
return 1;
|
|
101
105
|
}
|
|
102
106
|
catch (err) {
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readConfig, resolveTddCommitMode } from "../config.js";
|
|
5
|
+
import { readDelegationLedger } from "../delegation.js";
|
|
6
|
+
import { exists } from "../fs-utils.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
function parseCsv(raw) {
|
|
9
|
+
return raw
|
|
10
|
+
.split(",")
|
|
11
|
+
.map((value) => value.trim())
|
|
12
|
+
.filter((value) => value.length > 0);
|
|
13
|
+
}
|
|
14
|
+
function normalizePathLike(value) {
|
|
15
|
+
const slashes = value.replace(/\\/gu, "/");
|
|
16
|
+
const withoutDot = slashes.replace(/^\.\//u, "");
|
|
17
|
+
return withoutDot.replace(/\/+$/u, "");
|
|
18
|
+
}
|
|
19
|
+
function parseSliceCommitArgs(tokens) {
|
|
20
|
+
let sliceId = "";
|
|
21
|
+
let spanId = "";
|
|
22
|
+
let taskId;
|
|
23
|
+
let title;
|
|
24
|
+
let runId;
|
|
25
|
+
const claimedPaths = [];
|
|
26
|
+
let json = false;
|
|
27
|
+
let quiet = false;
|
|
28
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
29
|
+
const token = tokens[i];
|
|
30
|
+
const next = tokens[i + 1];
|
|
31
|
+
const valueFrom = (flag) => {
|
|
32
|
+
if (token.startsWith(`${flag}=`))
|
|
33
|
+
return token.slice(flag.length + 1);
|
|
34
|
+
if (token === flag && next && !next.startsWith("--")) {
|
|
35
|
+
i += 1;
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`${flag} requires a value.`);
|
|
39
|
+
};
|
|
40
|
+
if (token === "--json") {
|
|
41
|
+
json = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token === "--quiet") {
|
|
45
|
+
quiet = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (token.startsWith("--slice=") || token === "--slice") {
|
|
49
|
+
sliceId = valueFrom("--slice").trim();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (token.startsWith("--span-id=") || token === "--span-id") {
|
|
53
|
+
spanId = valueFrom("--span-id").trim();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (token.startsWith("--task-id=") || token === "--task-id") {
|
|
57
|
+
taskId = valueFrom("--task-id").trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (token.startsWith("--title=") || token === "--title") {
|
|
61
|
+
title = valueFrom("--title").trim();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (token.startsWith("--run-id=") || token === "--run-id") {
|
|
65
|
+
runId = valueFrom("--run-id").trim();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (token.startsWith("--claimed-paths=") || token === "--claimed-paths") {
|
|
69
|
+
claimedPaths.push(...parseCsv(valueFrom("--claimed-paths")));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (token.startsWith("--claimed-path=") || token === "--claimed-path") {
|
|
73
|
+
const one = valueFrom("--claimed-path").trim();
|
|
74
|
+
if (one.length > 0)
|
|
75
|
+
claimedPaths.push(one);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Unknown flag for internal slice-commit: ${token}`);
|
|
79
|
+
}
|
|
80
|
+
if (sliceId.length === 0) {
|
|
81
|
+
throw new Error("internal slice-commit requires --slice=<S-N>.");
|
|
82
|
+
}
|
|
83
|
+
if (spanId.length === 0) {
|
|
84
|
+
throw new Error("internal slice-commit requires --span-id=<span-id>.");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
sliceId,
|
|
88
|
+
spanId,
|
|
89
|
+
taskId,
|
|
90
|
+
title,
|
|
91
|
+
runId,
|
|
92
|
+
claimedPaths,
|
|
93
|
+
json,
|
|
94
|
+
quiet
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function output(io, args, payload, channel = "stdout") {
|
|
98
|
+
if (args.quiet && channel === "stdout")
|
|
99
|
+
return;
|
|
100
|
+
const writer = channel === "stdout" ? io.stdout : io.stderr;
|
|
101
|
+
if (args.json) {
|
|
102
|
+
writer.write(`${JSON.stringify(payload)}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const message = typeof payload.message === "string"
|
|
106
|
+
? payload.message
|
|
107
|
+
: JSON.stringify(payload);
|
|
108
|
+
writer.write(`${message}\n`);
|
|
109
|
+
}
|
|
110
|
+
function parsePorcelainPaths(raw) {
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const line of raw.split(/\r?\n/gu)) {
|
|
113
|
+
const trimmed = line.trimEnd();
|
|
114
|
+
if (trimmed.length < 4)
|
|
115
|
+
continue;
|
|
116
|
+
// porcelain line shape: XY<space><path>
|
|
117
|
+
const status = trimmed.slice(0, 2);
|
|
118
|
+
if (status === "??") {
|
|
119
|
+
const p = normalizePathLike(trimmed.slice(3).trim());
|
|
120
|
+
if (p.length > 0)
|
|
121
|
+
out.push(p);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let p = trimmed.slice(3).trim();
|
|
125
|
+
const renameIdx = p.indexOf(" -> ");
|
|
126
|
+
if (renameIdx >= 0) {
|
|
127
|
+
p = p.slice(renameIdx + 4);
|
|
128
|
+
}
|
|
129
|
+
p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
|
|
130
|
+
if (p.length > 0)
|
|
131
|
+
out.push(p);
|
|
132
|
+
}
|
|
133
|
+
return [...new Set(out)];
|
|
134
|
+
}
|
|
135
|
+
function matchesClaimedPath(changedPath, claimedPaths) {
|
|
136
|
+
const changed = normalizePathLike(changedPath);
|
|
137
|
+
return claimedPaths.some((rawClaimed) => {
|
|
138
|
+
const claimed = normalizePathLike(rawClaimed);
|
|
139
|
+
if (claimed.length === 0)
|
|
140
|
+
return false;
|
|
141
|
+
if (changed === claimed)
|
|
142
|
+
return true;
|
|
143
|
+
return changed.startsWith(`${claimed}/`);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function resolveClaimedPathsFromLedger(projectRoot, args) {
|
|
147
|
+
const ledger = await readDelegationLedger(projectRoot);
|
|
148
|
+
const matches = ledger.entries.filter((entry) => entry.stage === "tdd" &&
|
|
149
|
+
entry.agent === "slice-builder" &&
|
|
150
|
+
entry.sliceId === args.sliceId &&
|
|
151
|
+
entry.spanId === args.spanId &&
|
|
152
|
+
(!args.runId || entry.runId === args.runId) &&
|
|
153
|
+
Array.isArray(entry.claimedPaths) &&
|
|
154
|
+
entry.claimedPaths.length > 0);
|
|
155
|
+
matches.sort((a, b) => {
|
|
156
|
+
const aTs = a.ts ?? a.startTs ?? "";
|
|
157
|
+
const bTs = b.ts ?? b.startTs ?? "";
|
|
158
|
+
return aTs < bTs ? 1 : aTs > bTs ? -1 : 0;
|
|
159
|
+
});
|
|
160
|
+
const fromLedger = matches[0]?.claimedPaths ?? [];
|
|
161
|
+
return [...new Set(fromLedger.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))];
|
|
162
|
+
}
|
|
163
|
+
export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
164
|
+
let args;
|
|
165
|
+
try {
|
|
166
|
+
args = parseSliceCommitArgs(tokens);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
io.stderr.write(`cclaw internal slice-commit: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
const config = await readConfig(projectRoot).catch(() => null);
|
|
173
|
+
const commitMode = resolveTddCommitMode(config);
|
|
174
|
+
if (commitMode !== "managed-per-slice") {
|
|
175
|
+
output(io, args, {
|
|
176
|
+
ok: true,
|
|
177
|
+
skipped: true,
|
|
178
|
+
reason: "commit-mode-not-managed",
|
|
179
|
+
commitMode,
|
|
180
|
+
message: `slice-commit skipped: commitMode=${commitMode}`
|
|
181
|
+
});
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
const gitPresent = await exists(path.join(projectRoot, ".git"));
|
|
185
|
+
if (!gitPresent) {
|
|
186
|
+
output(io, args, {
|
|
187
|
+
ok: true,
|
|
188
|
+
skipped: true,
|
|
189
|
+
reason: "no-git",
|
|
190
|
+
message: "slice-commit skipped: .git is missing"
|
|
191
|
+
});
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const claimedPaths = args.claimedPaths.length > 0
|
|
195
|
+
? [...new Set(args.claimedPaths.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))]
|
|
196
|
+
: await resolveClaimedPathsFromLedger(projectRoot, args);
|
|
197
|
+
if (claimedPaths.length === 0) {
|
|
198
|
+
output(io, args, {
|
|
199
|
+
ok: false,
|
|
200
|
+
errorCode: "slice_commit_claimed_paths_missing",
|
|
201
|
+
details: {
|
|
202
|
+
sliceId: args.sliceId,
|
|
203
|
+
spanId: args.spanId
|
|
204
|
+
},
|
|
205
|
+
message: `slice_commit_claimed_paths_missing: no claimed paths for ${args.sliceId}/${args.spanId}`
|
|
206
|
+
}, "stderr");
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
|
|
210
|
+
cwd: projectRoot
|
|
211
|
+
});
|
|
212
|
+
const changedPaths = parsePorcelainPaths(statusRaw);
|
|
213
|
+
if (changedPaths.length === 0) {
|
|
214
|
+
output(io, args, {
|
|
215
|
+
ok: true,
|
|
216
|
+
skipped: true,
|
|
217
|
+
reason: "no-changes",
|
|
218
|
+
message: `slice-commit skipped: no working-tree changes for ${args.sliceId}`
|
|
219
|
+
});
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
const pathDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
|
|
223
|
+
if (pathDrift.length > 0) {
|
|
224
|
+
output(io, args, {
|
|
225
|
+
ok: false,
|
|
226
|
+
errorCode: "slice_commit_path_drift",
|
|
227
|
+
details: {
|
|
228
|
+
sliceId: args.sliceId,
|
|
229
|
+
spanId: args.spanId,
|
|
230
|
+
claimedPaths,
|
|
231
|
+
driftPaths: pathDrift
|
|
232
|
+
},
|
|
233
|
+
message: `slice_commit_path_drift: ${pathDrift.join(", ")}`
|
|
234
|
+
}, "stderr");
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
|
|
238
|
+
if (changedInClaim.length === 0) {
|
|
239
|
+
output(io, args, {
|
|
240
|
+
ok: true,
|
|
241
|
+
skipped: true,
|
|
242
|
+
reason: "claimed-paths-unchanged",
|
|
243
|
+
message: `slice-commit skipped: no changes within claimed paths for ${args.sliceId}`
|
|
244
|
+
});
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await execFileAsync("git", ["add", "--", ...claimedPaths], {
|
|
249
|
+
cwd: projectRoot
|
|
250
|
+
});
|
|
251
|
+
const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
|
|
252
|
+
const titlePart = args.title && args.title.length > 0 ? args.title : "slice update";
|
|
253
|
+
const header = `${args.sliceId}/${taskPart}: ${titlePart}`;
|
|
254
|
+
const body = [
|
|
255
|
+
`span-id: ${args.spanId}`,
|
|
256
|
+
`run-id: ${args.runId ?? "unknown"}`,
|
|
257
|
+
"phase-cycle: red->green->refactor->doc"
|
|
258
|
+
].join("\n");
|
|
259
|
+
await execFileAsync("git", ["commit", "-m", header, "-m", body], {
|
|
260
|
+
cwd: projectRoot
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
265
|
+
if (/nothing to commit/iu.test(message)) {
|
|
266
|
+
output(io, args, {
|
|
267
|
+
ok: true,
|
|
268
|
+
skipped: true,
|
|
269
|
+
reason: "nothing-to-commit",
|
|
270
|
+
message: `slice-commit skipped: nothing to commit for ${args.sliceId}`
|
|
271
|
+
});
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
output(io, args, {
|
|
275
|
+
ok: false,
|
|
276
|
+
errorCode: "slice_commit_failed",
|
|
277
|
+
details: { message },
|
|
278
|
+
message: `slice_commit_failed: ${message}`
|
|
279
|
+
}, "stderr");
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
const { stdout: shaStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
283
|
+
cwd: projectRoot
|
|
284
|
+
});
|
|
285
|
+
const commitSha = shaStdout.trim();
|
|
286
|
+
output(io, args, {
|
|
287
|
+
ok: true,
|
|
288
|
+
commitSha,
|
|
289
|
+
sliceId: args.sliceId,
|
|
290
|
+
spanId: args.spanId,
|
|
291
|
+
claimedPaths,
|
|
292
|
+
changedPaths: changedInClaim,
|
|
293
|
+
message: `slice commit created for ${args.sliceId}: ${commitSha}`
|
|
294
|
+
});
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
@@ -1,13 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readConfig, resolveTddCommitMode } from "./config.js";
|
|
5
|
+
import { readDelegationLedger } from "./delegation.js";
|
|
2
6
|
import { exists } from "./fs-utils.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
3
8
|
export const TEST_COMMAND_HINT_PATTERN = /\b(?:npm test|npm run test(?::[\w:-]+)?|pnpm test|pnpm [\w:-]*test[\w:-]*|yarn test|yarn [\w:-]*test[\w:-]*|bun test|bun run test(?::[\w:-]+)?|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|\.\/gradlew test|dotnet test)\b/iu;
|
|
4
9
|
export const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}\b/iu;
|
|
5
10
|
export const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
|
|
6
11
|
export const NO_VCS_ATTESTATION_PATTERN = /\b(?:no[-_ ]?vcs|no git|not a git repo|vcs\s*[:=]\s*none)\b/iu;
|
|
7
12
|
export const NO_VCS_HASH_PATTERN = /\b(?:content|artifact)[-_ ]?hash\s*[:=]\s*(?:sha256:)?[0-9a-f]{16,64}\b|\bsha256\s*[:=]\s*[0-9a-f]{16,64}\b/iu;
|
|
13
|
+
function escapeRegex(value) {
|
|
14
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
15
|
+
}
|
|
16
|
+
function hasRefactorCoverage(entries) {
|
|
17
|
+
const phases = new Set(entries
|
|
18
|
+
.filter((e) => e.status === "completed" && typeof e.phase === "string")
|
|
19
|
+
.map((e) => e.phase));
|
|
20
|
+
if (phases.has("refactor") || phases.has("refactor-deferred")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
const greenWithOutcome = entries.find((entry) => entry.status === "completed" &&
|
|
24
|
+
entry.phase === "green" &&
|
|
25
|
+
entry.refactorOutcome &&
|
|
26
|
+
(entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
|
|
27
|
+
if (!greenWithOutcome?.refactorOutcome)
|
|
28
|
+
return false;
|
|
29
|
+
if (greenWithOutcome.refactorOutcome.mode === "inline")
|
|
30
|
+
return true;
|
|
31
|
+
const rationale = greenWithOutcome.refactorOutcome.rationale;
|
|
32
|
+
if (typeof rationale === "string" && rationale.trim().length > 0)
|
|
33
|
+
return true;
|
|
34
|
+
if (!Array.isArray(greenWithOutcome.evidenceRefs))
|
|
35
|
+
return false;
|
|
36
|
+
return greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
37
|
+
}
|
|
38
|
+
function collectClosedSlices(entries, runId) {
|
|
39
|
+
const bySlice = new Map();
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.runId !== runId)
|
|
42
|
+
continue;
|
|
43
|
+
if (entry.stage !== "tdd")
|
|
44
|
+
continue;
|
|
45
|
+
if (entry.status !== "completed")
|
|
46
|
+
continue;
|
|
47
|
+
if (typeof entry.sliceId !== "string" || entry.sliceId.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
if (typeof entry.spanId !== "string" || entry.spanId.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
const bySpan = bySlice.get(entry.sliceId) ?? new Map();
|
|
52
|
+
const rows = bySpan.get(entry.spanId) ?? [];
|
|
53
|
+
rows.push(entry);
|
|
54
|
+
bySpan.set(entry.spanId, rows);
|
|
55
|
+
bySlice.set(entry.sliceId, bySpan);
|
|
56
|
+
}
|
|
57
|
+
const closedSlices = new Set();
|
|
58
|
+
for (const [sliceId, bySpan] of bySlice.entries()) {
|
|
59
|
+
for (const rows of bySpan.values()) {
|
|
60
|
+
const phases = new Set(rows
|
|
61
|
+
.filter((row) => row.status === "completed" && typeof row.phase === "string")
|
|
62
|
+
.map((row) => row.phase));
|
|
63
|
+
const hasRed = phases.has("red");
|
|
64
|
+
const hasGreen = phases.has("green");
|
|
65
|
+
const hasDoc = phases.has("doc");
|
|
66
|
+
if (hasRed && hasGreen && hasDoc && hasRefactorCoverage(rows)) {
|
|
67
|
+
closedSlices.add(sliceId);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...closedSlices].sort();
|
|
73
|
+
}
|
|
74
|
+
async function hasManagedCommitForSlice(projectRoot, sliceId) {
|
|
75
|
+
const grep = `^${escapeRegex(sliceId)}/`;
|
|
76
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%s%n%b", "--grep", grep], { cwd: projectRoot });
|
|
77
|
+
return stdout.trim().length > 0;
|
|
78
|
+
}
|
|
8
79
|
export async function validateTddVerificationEvidence(projectRoot, evidence, options = {}) {
|
|
9
80
|
const normalized = evidence.trim();
|
|
10
|
-
const
|
|
81
|
+
const config = await readConfig(projectRoot).catch(() => null);
|
|
82
|
+
const commitMode = resolveTddCommitMode(config);
|
|
83
|
+
const mode = commitMode === "off" ? "disabled" : "auto";
|
|
11
84
|
const gitPresent = await exists(path.join(projectRoot, ".git"));
|
|
12
85
|
const issues = [];
|
|
13
86
|
if (options.requireCommand !== false && !TEST_COMMAND_HINT_PATTERN.test(normalized)) {
|
|
@@ -16,16 +89,34 @@ export async function validateTddVerificationEvidence(projectRoot, evidence, opt
|
|
|
16
89
|
if (options.requirePassStatus !== false && !PASS_STATUS_PATTERN.test(normalized)) {
|
|
17
90
|
issues.push("GREEN repair needed: include explicit success status (for example `PASS` or `GREEN`).");
|
|
18
91
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
92
|
+
if (mode !== "disabled" && commitMode === "managed-per-slice" && gitPresent) {
|
|
93
|
+
const ledger = await readDelegationLedger(projectRoot).catch(() => null);
|
|
94
|
+
if (ledger && typeof ledger.runId === "string" && ledger.runId.length > 0) {
|
|
95
|
+
const closedSlices = collectClosedSlices(ledger.entries, ledger.runId);
|
|
96
|
+
const missing = [];
|
|
97
|
+
for (const sliceId of closedSlices) {
|
|
98
|
+
const hasCommit = await hasManagedCommitForSlice(projectRoot, sliceId).catch(() => false);
|
|
99
|
+
if (!hasCommit) {
|
|
100
|
+
missing.push(sliceId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
issues.push(`managed-per-slice commit check failed: missing git commit(s) for closed slice(s): ${missing.join(", ")}.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
26
107
|
}
|
|
27
|
-
else if (mode === "auto"
|
|
28
|
-
|
|
108
|
+
else if (mode === "auto") {
|
|
109
|
+
const hasSha = SHA_WITH_LABEL_PATTERN.test(normalized);
|
|
110
|
+
const hasNoVcs = NO_VCS_ATTESTATION_PATTERN.test(normalized);
|
|
111
|
+
if (gitPresent && !hasSha) {
|
|
112
|
+
issues.push("must include a commit SHA token prefixed with `sha` or `commit` (for example `sha: abc1234`).");
|
|
113
|
+
}
|
|
114
|
+
else if (!gitPresent && !hasSha && !hasNoVcs) {
|
|
115
|
+
issues.push("must include either a commit SHA or an explicit no-VCS attestation (for example `no-vcs: project has no .git directory`).");
|
|
116
|
+
}
|
|
117
|
+
else if (!gitPresent && hasNoVcs && !NO_VCS_HASH_PATTERN.test(normalized)) {
|
|
118
|
+
issues.push("NO_VCS_MODE repair needed: include a content/artifact hash for no-VCS TDD evidence (for example `artifact-hash: sha256:<hash>`).");
|
|
119
|
+
}
|
|
29
120
|
}
|
|
30
121
|
return { ok: issues.length === 0, issues, mode, gitPresent };
|
|
31
122
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -161,10 +161,22 @@ export interface ReviewLoopConfig {
|
|
|
161
161
|
externalSecondOpinion?: ReviewLoopExternalSecondOpinionConfig;
|
|
162
162
|
}
|
|
163
163
|
export type VcsMode = "git-with-remote" | "git-local-only" | "none";
|
|
164
|
+
export type TddCommitMode = "managed-per-slice" | "agent-required" | "checkpoint-only" | "off";
|
|
165
|
+
export interface TddConfig {
|
|
166
|
+
/**
|
|
167
|
+
* Commit ownership model for closed TDD slices.
|
|
168
|
+
* - managed-per-slice: cclaw-generated hook performs one commit per closed slice.
|
|
169
|
+
* - agent-required: worker/controller must create the commit outside cclaw.
|
|
170
|
+
* - checkpoint-only: coarse-grained checkpoints are allowed (no per-slice enforcement).
|
|
171
|
+
* - off: skip commit-shape enforcement.
|
|
172
|
+
*/
|
|
173
|
+
commitMode?: TddCommitMode;
|
|
174
|
+
}
|
|
164
175
|
export interface CclawConfig {
|
|
165
176
|
version: string;
|
|
166
177
|
flowVersion: string;
|
|
167
178
|
harnesses: HarnessId[];
|
|
179
|
+
tdd?: TddConfig;
|
|
168
180
|
}
|
|
169
181
|
export interface TransitionRule {
|
|
170
182
|
from: FlowStage;
|