cclaw-cli 6.9.0 → 6.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifact-linter/plan.js +37 -0
- package/dist/artifact-linter/shared.d.ts +48 -2
- package/dist/artifact-linter/shared.js +52 -4
- package/dist/artifact-linter/tdd.d.ts +20 -0
- package/dist/artifact-linter/tdd.js +187 -14
- package/dist/artifact-linter.js +87 -2
- package/dist/content/examples.js +9 -9
- package/dist/content/hooks.js +135 -2
- package/dist/content/reference-patterns.js +2 -2
- package/dist/content/skills.js +1 -1
- package/dist/content/stages/tdd.js +6 -8
- package/dist/content/subagents.js +9 -1
- package/dist/content/templates.js +5 -15
- package/dist/delegation.d.ts +92 -0
- package/dist/delegation.js +159 -10
- package/dist/internal/advance-stage.js +19 -3
- package/dist/internal/plan-split-waves.d.ts +66 -0
- package/dist/internal/plan-split-waves.js +249 -0
- package/dist/tdd-slices.d.ts +90 -0
- package/dist/tdd-slices.js +375 -0
- package/package.json +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
|
+
import { exists, withDirectoryLock } from "./fs-utils.js";
|
|
5
|
+
import { readFlowState } from "./runs.js";
|
|
6
|
+
export const TDD_SLICE_LEDGER_FILENAME = "06-tdd-slices.jsonl";
|
|
7
|
+
export const TDD_SLICE_LEDGER_SCHEMA_VERSION = 1;
|
|
8
|
+
export const TDD_SLICE_STATUSES = [
|
|
9
|
+
"red",
|
|
10
|
+
"green",
|
|
11
|
+
"refactor-deferred",
|
|
12
|
+
"refactor-done"
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve `<artifacts-dir>/06-tdd-slices.jsonl`. Mirrors the convention used
|
|
16
|
+
* by the rest of the runtime (see `artifact-paths.ts::searchRoots`): the
|
|
17
|
+
* sidecar always lives under `.cclaw/artifacts/` regardless of the active
|
|
18
|
+
* topic slug for the TDD artifact.
|
|
19
|
+
*/
|
|
20
|
+
export function tddSliceLedgerPath(projectRoot) {
|
|
21
|
+
return path.join(projectRoot, RUNTIME_ROOT, "artifacts", TDD_SLICE_LEDGER_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
function tddSliceLedgerLockPath(projectRoot) {
|
|
24
|
+
return path.join(projectRoot, RUNTIME_ROOT, "artifacts", `.${TDD_SLICE_LEDGER_FILENAME}.lock`);
|
|
25
|
+
}
|
|
26
|
+
function isStringArray(value) {
|
|
27
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
28
|
+
}
|
|
29
|
+
export function isTddSliceLedgerEntry(value) {
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
31
|
+
return false;
|
|
32
|
+
const o = value;
|
|
33
|
+
if (typeof o.runId !== "string" || o.runId.length === 0)
|
|
34
|
+
return false;
|
|
35
|
+
if (typeof o.sliceId !== "string" || o.sliceId.length === 0)
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof o.status !== "string" || !TDD_SLICE_STATUSES.includes(o.status)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (typeof o.testFile !== "string")
|
|
41
|
+
return false;
|
|
42
|
+
if (typeof o.testCommand !== "string")
|
|
43
|
+
return false;
|
|
44
|
+
if (!isStringArray(o.claimedPaths))
|
|
45
|
+
return false;
|
|
46
|
+
if (o.redObservedAt !== undefined && typeof o.redObservedAt !== "string")
|
|
47
|
+
return false;
|
|
48
|
+
if (o.redOutputRef !== undefined && typeof o.redOutputRef !== "string")
|
|
49
|
+
return false;
|
|
50
|
+
if (o.greenAt !== undefined && typeof o.greenAt !== "string")
|
|
51
|
+
return false;
|
|
52
|
+
if (o.greenOutputRef !== undefined && typeof o.greenOutputRef !== "string")
|
|
53
|
+
return false;
|
|
54
|
+
if (o.refactorAt !== undefined && typeof o.refactorAt !== "string")
|
|
55
|
+
return false;
|
|
56
|
+
if (o.refactorRationale !== undefined && typeof o.refactorRationale !== "string")
|
|
57
|
+
return false;
|
|
58
|
+
if (o.acceptanceCriterionId !== undefined &&
|
|
59
|
+
typeof o.acceptanceCriterionId !== "string") {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (o.planUnitId !== undefined && typeof o.planUnitId !== "string")
|
|
63
|
+
return false;
|
|
64
|
+
if (o.schemaVersion !== TDD_SLICE_LEDGER_SCHEMA_VERSION)
|
|
65
|
+
return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
export async function readTddSliceLedger(projectRoot) {
|
|
69
|
+
const filePath = tddSliceLedgerPath(projectRoot);
|
|
70
|
+
if (!(await exists(filePath))) {
|
|
71
|
+
return { entries: [], corruptLines: [] };
|
|
72
|
+
}
|
|
73
|
+
const text = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
74
|
+
const lines = text.split(/\r?\n/gu);
|
|
75
|
+
const entries = [];
|
|
76
|
+
const corruptLines = [];
|
|
77
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
78
|
+
const line = lines[index]?.trim() ?? "";
|
|
79
|
+
if (line.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(line);
|
|
83
|
+
if (isTddSliceLedgerEntry(parsed)) {
|
|
84
|
+
entries.push(parsed);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
corruptLines.push(index + 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
corruptLines.push(index + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { entries, corruptLines };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Latest-row-wins fold by `sliceId`. Returns one entry per slice, ordered by
|
|
98
|
+
* the index of its latest row. Mirrors the pattern used by
|
|
99
|
+
* `computeActiveSubagents` for the delegation ledger.
|
|
100
|
+
*/
|
|
101
|
+
export function foldTddSliceLedger(entries) {
|
|
102
|
+
const latest = new Map();
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
latest.set(entry.sliceId, entry);
|
|
105
|
+
}
|
|
106
|
+
return [...latest.values()];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Atomic append under a directory lock — reuses the same `withDirectoryLock`
|
|
110
|
+
* primitive that `appendDelegation` uses so concurrent CLI invocations don't
|
|
111
|
+
* tear a half-written JSON line.
|
|
112
|
+
*/
|
|
113
|
+
export async function appendSliceEntry(projectRoot, entry) {
|
|
114
|
+
const filePath = tddSliceLedgerPath(projectRoot);
|
|
115
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
116
|
+
await withDirectoryLock(tddSliceLedgerLockPath(projectRoot), async () => {
|
|
117
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, {
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
mode: 0o600
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Whether the exact same row (by all fields except timestamps) already
|
|
125
|
+
* exists. Used to make CLI retries idempotent — a hook that re-runs after
|
|
126
|
+
* a transient failure should not double-append.
|
|
127
|
+
*/
|
|
128
|
+
function entriesEquivalent(a, b) {
|
|
129
|
+
return (a.runId === b.runId &&
|
|
130
|
+
a.sliceId === b.sliceId &&
|
|
131
|
+
a.status === b.status &&
|
|
132
|
+
a.testFile === b.testFile &&
|
|
133
|
+
a.testCommand === b.testCommand &&
|
|
134
|
+
a.redObservedAt === b.redObservedAt &&
|
|
135
|
+
a.redOutputRef === b.redOutputRef &&
|
|
136
|
+
a.greenAt === b.greenAt &&
|
|
137
|
+
a.greenOutputRef === b.greenOutputRef &&
|
|
138
|
+
a.refactorAt === b.refactorAt &&
|
|
139
|
+
a.refactorRationale === b.refactorRationale &&
|
|
140
|
+
a.acceptanceCriterionId === b.acceptanceCriterionId &&
|
|
141
|
+
a.planUnitId === b.planUnitId &&
|
|
142
|
+
a.claimedPaths.length === b.claimedPaths.length &&
|
|
143
|
+
a.claimedPaths.every((p, i) => p === b.claimedPaths[i]));
|
|
144
|
+
}
|
|
145
|
+
function readFlagValue(tokens, index, flag) {
|
|
146
|
+
const token = tokens[index];
|
|
147
|
+
if (token.startsWith(`${flag}=`)) {
|
|
148
|
+
return { value: token.slice(flag.length + 1), advance: 0 };
|
|
149
|
+
}
|
|
150
|
+
const next = tokens[index + 1];
|
|
151
|
+
if (token === flag) {
|
|
152
|
+
if (next === undefined || next.startsWith("--")) {
|
|
153
|
+
throw new Error(`${flag} requires a value.`);
|
|
154
|
+
}
|
|
155
|
+
return { value: next, advance: 1 };
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`${flag} requires a value.`);
|
|
158
|
+
}
|
|
159
|
+
function parseClaimedPaths(raw) {
|
|
160
|
+
return raw
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((item) => item.trim())
|
|
163
|
+
.filter((item) => item.length > 0);
|
|
164
|
+
}
|
|
165
|
+
export function parseTddSliceRecordArgs(tokens) {
|
|
166
|
+
let sliceId;
|
|
167
|
+
let status;
|
|
168
|
+
let testFile;
|
|
169
|
+
let testCommand;
|
|
170
|
+
let claimedPaths;
|
|
171
|
+
let redOutputRef;
|
|
172
|
+
let greenOutputRef;
|
|
173
|
+
let redObservedAt;
|
|
174
|
+
let greenAt;
|
|
175
|
+
let refactorAt;
|
|
176
|
+
let refactorRationale;
|
|
177
|
+
let acceptanceCriterionId;
|
|
178
|
+
let planUnitId;
|
|
179
|
+
let json = false;
|
|
180
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
181
|
+
const token = tokens[i];
|
|
182
|
+
if (token === "--json") {
|
|
183
|
+
json = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (token === "--slice" || token.startsWith("--slice=")) {
|
|
187
|
+
const { value, advance } = readFlagValue(tokens, i, "--slice");
|
|
188
|
+
sliceId = value.trim();
|
|
189
|
+
i += advance;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (token === "--status" || token.startsWith("--status=")) {
|
|
193
|
+
const { value, advance } = readFlagValue(tokens, i, "--status");
|
|
194
|
+
const trimmed = value.trim();
|
|
195
|
+
if (!TDD_SLICE_STATUSES.includes(trimmed)) {
|
|
196
|
+
throw new Error(`--status must be one of ${TDD_SLICE_STATUSES.join("|")}`);
|
|
197
|
+
}
|
|
198
|
+
status = trimmed;
|
|
199
|
+
i += advance;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (token === "--test-file" || token.startsWith("--test-file=")) {
|
|
203
|
+
const { value, advance } = readFlagValue(tokens, i, "--test-file");
|
|
204
|
+
testFile = value.trim();
|
|
205
|
+
i += advance;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (token === "--command" || token.startsWith("--command=")) {
|
|
209
|
+
const { value, advance } = readFlagValue(tokens, i, "--command");
|
|
210
|
+
testCommand = value.trim();
|
|
211
|
+
i += advance;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (token === "--paths" || token.startsWith("--paths=")) {
|
|
215
|
+
const { value, advance } = readFlagValue(tokens, i, "--paths");
|
|
216
|
+
claimedPaths = parseClaimedPaths(value);
|
|
217
|
+
i += advance;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (token === "--red-output-ref" || token.startsWith("--red-output-ref=")) {
|
|
221
|
+
const { value, advance } = readFlagValue(tokens, i, "--red-output-ref");
|
|
222
|
+
redOutputRef = value.trim();
|
|
223
|
+
i += advance;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (token === "--green-output-ref" || token.startsWith("--green-output-ref=")) {
|
|
227
|
+
const { value, advance } = readFlagValue(tokens, i, "--green-output-ref");
|
|
228
|
+
greenOutputRef = value.trim();
|
|
229
|
+
i += advance;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (token === "--red-observed-at" || token.startsWith("--red-observed-at=")) {
|
|
233
|
+
const { value, advance } = readFlagValue(tokens, i, "--red-observed-at");
|
|
234
|
+
redObservedAt = value.trim();
|
|
235
|
+
i += advance;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (token === "--green-at" || token.startsWith("--green-at=")) {
|
|
239
|
+
const { value, advance } = readFlagValue(tokens, i, "--green-at");
|
|
240
|
+
greenAt = value.trim();
|
|
241
|
+
i += advance;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (token === "--refactor-at" || token.startsWith("--refactor-at=")) {
|
|
245
|
+
const { value, advance } = readFlagValue(tokens, i, "--refactor-at");
|
|
246
|
+
refactorAt = value.trim();
|
|
247
|
+
i += advance;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (token === "--refactor-rationale" || token.startsWith("--refactor-rationale=")) {
|
|
251
|
+
const { value, advance } = readFlagValue(tokens, i, "--refactor-rationale");
|
|
252
|
+
refactorRationale = value.trim();
|
|
253
|
+
i += advance;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (token === "--ac" || token.startsWith("--ac=")) {
|
|
257
|
+
const { value, advance } = readFlagValue(tokens, i, "--ac");
|
|
258
|
+
acceptanceCriterionId = value.trim();
|
|
259
|
+
i += advance;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (token === "--plan-unit" || token.startsWith("--plan-unit=")) {
|
|
263
|
+
const { value, advance } = readFlagValue(tokens, i, "--plan-unit");
|
|
264
|
+
planUnitId = value.trim();
|
|
265
|
+
i += advance;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`Unknown flag for internal tdd-slice-record: ${token}`);
|
|
269
|
+
}
|
|
270
|
+
if (!sliceId) {
|
|
271
|
+
throw new Error("internal tdd-slice-record requires --slice <id>.");
|
|
272
|
+
}
|
|
273
|
+
if (!status) {
|
|
274
|
+
throw new Error(`internal tdd-slice-record requires --status <${TDD_SLICE_STATUSES.join("|")}>.`);
|
|
275
|
+
}
|
|
276
|
+
if (status === "refactor-deferred" && (!refactorRationale || refactorRationale.length === 0)) {
|
|
277
|
+
throw new Error("internal tdd-slice-record: --status=refactor-deferred requires --refactor-rationale=<text>.");
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
sliceId,
|
|
281
|
+
status,
|
|
282
|
+
testFile,
|
|
283
|
+
testCommand,
|
|
284
|
+
claimedPaths,
|
|
285
|
+
redOutputRef,
|
|
286
|
+
greenOutputRef,
|
|
287
|
+
redObservedAt,
|
|
288
|
+
greenAt,
|
|
289
|
+
refactorAt,
|
|
290
|
+
refactorRationale,
|
|
291
|
+
acceptanceCriterionId,
|
|
292
|
+
planUnitId,
|
|
293
|
+
json
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Consume parsed CLI flags, fold against the existing sidecar to inherit
|
|
298
|
+
* fields recorded on earlier rows of the same slice, auto-stamp the
|
|
299
|
+
* status-relevant timestamp when not provided, and append the new row.
|
|
300
|
+
*
|
|
301
|
+
* The CLI surface is intentionally lenient: only the very first call for a
|
|
302
|
+
* slice (status=red) needs `--test-file`, `--command`, `--paths`. Subsequent
|
|
303
|
+
* green/refactor calls inherit those values from the latest prior row.
|
|
304
|
+
*/
|
|
305
|
+
export async function runTddSliceRecord(projectRoot, args, io) {
|
|
306
|
+
const { activeRunId } = await readFlowState(projectRoot);
|
|
307
|
+
const ledger = await readTddSliceLedger(projectRoot);
|
|
308
|
+
const priorForSlice = ledger.entries.filter((entry) => entry.sliceId === args.sliceId);
|
|
309
|
+
const latestPrior = priorForSlice.length > 0 ? priorForSlice[priorForSlice.length - 1] : null;
|
|
310
|
+
const testFile = args.testFile ?? latestPrior?.testFile ?? "";
|
|
311
|
+
const testCommand = args.testCommand ?? latestPrior?.testCommand ?? "";
|
|
312
|
+
const claimedPaths = args.claimedPaths ?? latestPrior?.claimedPaths ?? [];
|
|
313
|
+
if (args.status === "red") {
|
|
314
|
+
if (testFile.length === 0) {
|
|
315
|
+
throw new Error("--status=red requires --test-file=<path> on the first call for a slice.");
|
|
316
|
+
}
|
|
317
|
+
if (testCommand.length === 0) {
|
|
318
|
+
throw new Error("--status=red requires --command=<cmd>.");
|
|
319
|
+
}
|
|
320
|
+
if (claimedPaths.length === 0) {
|
|
321
|
+
throw new Error("--status=red requires --paths=<comma-separated>.");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
const inheritedRedObservedAt = args.redObservedAt
|
|
326
|
+
?? latestPrior?.redObservedAt
|
|
327
|
+
?? (args.status === "red" ? now : undefined);
|
|
328
|
+
const inheritedGreenAt = args.greenAt
|
|
329
|
+
?? latestPrior?.greenAt
|
|
330
|
+
?? (args.status === "green" ? now : undefined);
|
|
331
|
+
const inheritedRefactorAt = args.refactorAt
|
|
332
|
+
?? latestPrior?.refactorAt
|
|
333
|
+
?? (args.status === "refactor-done" ? now : undefined);
|
|
334
|
+
const entry = {
|
|
335
|
+
runId: activeRunId,
|
|
336
|
+
sliceId: args.sliceId,
|
|
337
|
+
status: args.status,
|
|
338
|
+
testFile,
|
|
339
|
+
testCommand,
|
|
340
|
+
claimedPaths,
|
|
341
|
+
schemaVersion: TDD_SLICE_LEDGER_SCHEMA_VERSION,
|
|
342
|
+
...(inheritedRedObservedAt !== undefined ? { redObservedAt: inheritedRedObservedAt } : {}),
|
|
343
|
+
...(args.redOutputRef ?? latestPrior?.redOutputRef
|
|
344
|
+
? { redOutputRef: args.redOutputRef ?? latestPrior?.redOutputRef }
|
|
345
|
+
: {}),
|
|
346
|
+
...(inheritedGreenAt !== undefined ? { greenAt: inheritedGreenAt } : {}),
|
|
347
|
+
...(args.greenOutputRef ?? latestPrior?.greenOutputRef
|
|
348
|
+
? { greenOutputRef: args.greenOutputRef ?? latestPrior?.greenOutputRef }
|
|
349
|
+
: {}),
|
|
350
|
+
...(inheritedRefactorAt !== undefined ? { refactorAt: inheritedRefactorAt } : {}),
|
|
351
|
+
...(args.refactorRationale ?? latestPrior?.refactorRationale
|
|
352
|
+
? { refactorRationale: args.refactorRationale ?? latestPrior?.refactorRationale }
|
|
353
|
+
: {}),
|
|
354
|
+
...(args.acceptanceCriterionId ?? latestPrior?.acceptanceCriterionId
|
|
355
|
+
? { acceptanceCriterionId: args.acceptanceCriterionId ?? latestPrior?.acceptanceCriterionId }
|
|
356
|
+
: {}),
|
|
357
|
+
...(args.planUnitId ?? latestPrior?.planUnitId
|
|
358
|
+
? { planUnitId: args.planUnitId ?? latestPrior?.planUnitId }
|
|
359
|
+
: {})
|
|
360
|
+
};
|
|
361
|
+
if (latestPrior && entriesEquivalent(latestPrior, entry)) {
|
|
362
|
+
if (args.json) {
|
|
363
|
+
io.stdout.write(`${JSON.stringify({ ok: true, command: "tdd-slice-record", idempotent: true, entry })}\n`);
|
|
364
|
+
}
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
await appendSliceEntry(projectRoot, entry);
|
|
368
|
+
if (args.json) {
|
|
369
|
+
io.stdout.write(`${JSON.stringify({ ok: true, command: "tdd-slice-record", entry })}\n`);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
io.stdout.write(`Recorded TDD slice ${entry.sliceId} status=${entry.status} runId=${entry.runId}\n`);
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
375
|
+
}
|