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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "6.9.0",
3
+ "version": "6.10.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {