cclaw-cli 0.48.18 → 0.48.20

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.
@@ -368,13 +368,31 @@ function normalizeText(value) {
368
368
  return String(value || "").replace(/\\s+/gu, " ").trim();
369
369
  }
370
370
 
371
+ // Mirrors \`src/tdd-cycle.ts::normalizeTddPath\`. Any change to
372
+ // canonical normalization must be updated in BOTH places; the
373
+ // tdd-parity test asserts matcher behavior agrees end-to-end.
371
374
  function normalizePathForMatch(rawPath) {
372
- return normalizeText(rawPath)
375
+ return String(rawPath == null ? "" : rawPath)
376
+ .trim()
373
377
  .replace(/\\\\/gu, "/")
374
378
  .replace(/^\\.\\//u, "")
375
379
  .toLowerCase();
376
380
  }
377
381
 
382
+ // Mirrors \`src/tdd-cycle.ts::pathMatchesTarget\`. Use instead of raw
383
+ // \`===\` when checking recorded files against a target path.
384
+ function pathMatchesTargetInline(candidate, target) {
385
+ const normalizedCandidate = normalizePathForMatch(candidate);
386
+ const normalizedTarget = normalizePathForMatch(target);
387
+ if (normalizedCandidate.length === 0 || normalizedTarget.length === 0) {
388
+ return false;
389
+ }
390
+ return (
391
+ normalizedCandidate === normalizedTarget ||
392
+ normalizedCandidate.endsWith("/" + normalizedTarget)
393
+ );
394
+ }
395
+
378
396
  function normalizeToolName(value) {
379
397
  if (typeof value === "string" && value.trim().length > 0) return value.trim();
380
398
  if (value && typeof value === "object") {
@@ -1279,27 +1297,6 @@ async function handlePromptGuard(runtime) {
1279
1297
  return 0;
1280
1298
  }
1281
1299
 
1282
- async function tddCycleCounts(stateDir, runId) {
1283
- const filePath = path.join(stateDir, "tdd-cycle-log.jsonl");
1284
- const raw = await readTextFile(filePath, "");
1285
- const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
1286
- let red = 0;
1287
- let green = 0;
1288
- for (const line of lines) {
1289
- try {
1290
- const row = JSON.parse(line);
1291
- if (!row || typeof row !== "object" || Array.isArray(row)) continue;
1292
- const rowRun = typeof row.runId === "string" && row.runId.length > 0 ? row.runId : runId;
1293
- if (rowRun !== runId) continue;
1294
- if (row.phase === "red") red += 1;
1295
- if (row.phase === "green") green += 1;
1296
- } catch {
1297
- // ignore malformed rows
1298
- }
1299
- }
1300
- return { red, green };
1301
- }
1302
-
1303
1300
  // Mirrors src/knowledge-store.ts::computeCompoundReadiness — kept inline so
1304
1301
  // SessionStart can refresh compound-readiness.json without the CLI binary.
1305
1302
  // Any schema change must update src/knowledge-store.ts::computeCompoundReadiness
@@ -1458,14 +1455,7 @@ async function computeRalphLoopStatusInline(stateDir, runId) {
1458
1455
  };
1459
1456
  }
1460
1457
 
1461
- function tddCycleStateFromCounts(counts) {
1462
- if (counts.red <= 0) return "need_red";
1463
- if (counts.red > counts.green) return "red_open";
1464
- return "green_done";
1465
- }
1466
-
1467
1458
  async function hasFailingRedEvidenceForPath(stateDir, runId, rawPath) {
1468
- const normalizedTarget = normalizePathForMatch(rawPath);
1469
1459
  const cycleRaw = await readTextFile(path.join(stateDir, "tdd-cycle-log.jsonl"), "");
1470
1460
  for (const line of cycleRaw.split(/\\r?\\n/gu)) {
1471
1461
  const trimmed = line.trim();
@@ -1484,7 +1474,10 @@ async function hasFailingRedEvidenceForPath(stateDir, runId, rawPath) {
1484
1474
  const files = Array.isArray(row.files) ? row.files : [];
1485
1475
  for (const filePath of files) {
1486
1476
  if (typeof filePath !== "string") continue;
1487
- if (normalizePathForMatch(filePath) === normalizedTarget) return true;
1477
+ // endsWith-aware match (mirrors tdd-cycle.ts::pathMatchesTarget)
1478
+ // — previously the inline impl used strict === which disagreed
1479
+ // with the CLI/internal path and produced guard blind spots.
1480
+ if (pathMatchesTargetInline(filePath, rawPath)) return true;
1488
1481
  }
1489
1482
  } catch {
1490
1483
  // ignore malformed line
@@ -1508,7 +1501,7 @@ async function hasFailingRedEvidenceForPath(stateDir, runId, rawPath) {
1508
1501
  const paths = Array.isArray(row.paths) ? row.paths : [];
1509
1502
  for (const filePath of paths) {
1510
1503
  if (typeof filePath !== "string") continue;
1511
- if (normalizePathForMatch(filePath) === normalizedTarget) return true;
1504
+ if (pathMatchesTargetInline(filePath, rawPath)) return true;
1512
1505
  }
1513
1506
  } catch {
1514
1507
  // ignore malformed line
@@ -1667,9 +1660,14 @@ async function handleWorkflowGuard(runtime) {
1667
1660
  reasons.push("tdd_write_without_red_for_path");
1668
1661
  }
1669
1662
  } else if (productionPatterns.length === 0 && !isTestPayload(payloadLower, payloadPaths, testPatterns)) {
1670
- const counts = await tddCycleCounts(stateDir, currentRun);
1671
- const cycleState = tddCycleStateFromCounts(counts);
1672
- if (cycleState === "need_red") {
1663
+ // Slice-aware fallback: the previous implementation used a flat
1664
+ // red/green count which said "ok" as long as the totals balanced
1665
+ // across ALL slices, so a closed S-1 could unlock production
1666
+ // writes that actually belonged to a new, not-yet-red S-2. Now
1667
+ // we reuse the canonical Ralph Loop status: if NO slice has an
1668
+ // open RED, we block.
1669
+ const ralphStatus = await computeRalphLoopStatusInline(stateDir, currentRun);
1670
+ if (!ralphStatus.redOpen) {
1673
1671
  reasons.push("tdd_write_without_open_red");
1674
1672
  }
1675
1673
  }
@@ -2,10 +2,10 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "../constants.js";
4
4
  import { readFlowState } from "../runs.js";
5
- import { hasFailingTestForPath, parseTddCycleLog } from "../tdd-cycle.js";
6
- function normalizePath(value) {
7
- return value.replace(/\\/gu, "/").toLowerCase();
8
- }
5
+ import { hasFailingTestForPath, parseTddCycleLog, pathMatchesTarget } from "../tdd-cycle.js";
6
+ // normalizePath and the path matcher live in src/tdd-cycle.ts so all
7
+ // TDD-related guards (internal CLI, runtime hook, unit tests) agree on
8
+ // what "path X matches recorded file Y" means.
9
9
  function parseArgs(tokens) {
10
10
  const args = { quiet: false };
11
11
  for (const token of tokens) {
@@ -73,15 +73,12 @@ function parseAutoEvidence(text) {
73
73
  return out;
74
74
  }
75
75
  function hasFailingAutoEvidenceForPath(entries, targetPath, options = {}) {
76
- const normalizedTarget = normalizePath(targetPath);
77
76
  for (const entry of entries) {
78
77
  if (options.runId && entry.runId !== options.runId)
79
78
  continue;
80
79
  for (const filePath of entry.paths) {
81
- const normalized = normalizePath(filePath);
82
- if (normalized === normalizedTarget || normalized.endsWith(`/${normalizedTarget}`)) {
80
+ if (pathMatchesTarget(filePath, targetPath))
83
81
  return true;
84
- }
85
82
  }
86
83
  }
87
84
  return false;
@@ -89,14 +86,40 @@ function hasFailingAutoEvidenceForPath(entries, targetPath, options = {}) {
89
86
  export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
90
87
  const args = parseArgs(tokens);
91
88
  const flowState = await readFlowState(projectRoot).catch(() => null);
89
+ // Strict runId scoping: a previous implementation fell back to no
90
+ // filter when both `--runId` and `flowState.activeRunId` were missing,
91
+ // which let evidence rows from past runs satisfy the current check
92
+ // (false positive). Now: require an explicit or inferred runId or
93
+ // fail loud so the caller cannot silently inherit cross-run state.
92
94
  const effectiveRunId = args.runId ?? flowState?.activeRunId;
95
+ if (!effectiveRunId || effectiveRunId.trim().length === 0) {
96
+ const reason = "tdd-red-evidence: cannot scope check — no --runId provided and " +
97
+ "flow-state.json has no activeRunId. Pass --runId=<id> explicitly " +
98
+ "or run `cclaw doctor` to reconcile state.";
99
+ if (!args.quiet) {
100
+ io.stdout.write(`${JSON.stringify({
101
+ ok: false,
102
+ path: args.targetPath,
103
+ runId: null,
104
+ error: reason,
105
+ sources: { tddCycleLog: false, autoEvidence: false }
106
+ }, null, 2)}\n`);
107
+ }
108
+ else {
109
+ io.stderr.write(`${reason}\n`);
110
+ }
111
+ return 2;
112
+ }
93
113
  const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
94
114
  const autoEvidencePath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-red-evidence.jsonl");
95
115
  let cycleLogHasRed = false;
96
116
  let autoEvidenceHasRed = false;
97
117
  try {
98
118
  const raw = await fs.readFile(tddLogPath, "utf8");
99
- const entries = parseTddCycleLog(raw);
119
+ // Strict parse: drop malformed/underspecified rows rather than
120
+ // backfilling runId=active / stage=tdd defaults, which used to
121
+ // silently glue foreign entries to the current run.
122
+ const entries = parseTddCycleLog(raw, { strict: true });
100
123
  cycleLogHasRed = hasFailingTestForPath(entries, args.targetPath, {
101
124
  runId: effectiveRunId
102
125
  });
@@ -119,7 +142,7 @@ export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
119
142
  io.stdout.write(`${JSON.stringify({
120
143
  ok: hasRed,
121
144
  path: args.targetPath,
122
- runId: effectiveRunId ?? null,
145
+ runId: effectiveRunId,
123
146
  sources: {
124
147
  tddCycleLog: cycleLogHasRed,
125
148
  autoEvidence: autoEvidenceHasRed
@@ -22,10 +22,48 @@ export interface TddCycleValidation {
22
22
  openRedSlices: string[];
23
23
  sliceCount: number;
24
24
  }
25
- export declare function parseTddCycleLog(text: string): TddCycleEntry[];
25
+ export interface TddCycleParseIssue {
26
+ lineNumber: number;
27
+ reason: string;
28
+ rawLine: string;
29
+ }
30
+ export interface ParseTddCycleLogOptions {
31
+ /**
32
+ * Collect one issue per dropped/malformed line. Callers that care
33
+ * (doctor, red-evidence) can surface them; hooks keep soft-fail.
34
+ */
35
+ issues?: TddCycleParseIssue[];
36
+ /**
37
+ * When true, reject lines that omit required fields instead of
38
+ * back-filling them with defaults. Used by validation paths
39
+ * (`validateTddCycleOrder`, `cclaw doctor`) to avoid silently
40
+ * bucketing unscoped rows into "runId=active, stage=tdd". Soft paths
41
+ * (generated hooks) keep the legacy defaults so a half-written file
42
+ * never takes the session down.
43
+ */
44
+ strict?: boolean;
45
+ }
46
+ export declare function parseTddCycleLog(text: string, options?: ParseTddCycleLogOptions): TddCycleEntry[];
26
47
  export declare function validateTddCycleOrder(entries: TddCycleEntry[], options?: {
27
48
  runId?: string;
28
49
  }): TddCycleValidation;
50
+ /**
51
+ * Canonical path normalization used by ALL TDD path-matching layers
52
+ * (cycle-log analysis, internal CLI `tdd-red-evidence`, and the
53
+ * inline runtime hook). Previously each layer had its own near-copy,
54
+ * which produced subtle drift (e.g. `./src/app.ts` vs `src/app.ts`
55
+ * differing between hook and CLI). Keep this function in one place
56
+ * so all callers agree.
57
+ */
58
+ export declare function normalizeTddPath(value: string): string;
59
+ /**
60
+ * Shared "does the recorded file path refer to the target" matcher.
61
+ * Uses canonical normalization plus the traditional `endsWith('/'+x)`
62
+ * rule so a recorded `src/app.ts` matches either `src/app.ts` or
63
+ * `subdir/src/app.ts` but NOT `src/app.test.ts`. All TDD path checks
64
+ * MUST go through this helper.
65
+ */
66
+ export declare function pathMatchesTarget(candidate: string, target: string): boolean;
29
67
  export interface RalphLoopSliceState {
30
68
  slice: string;
31
69
  redCount: number;
package/dist/tdd-cycle.js CHANGED
@@ -1,41 +1,77 @@
1
- export function parseTddCycleLog(text) {
1
+ export function parseTddCycleLog(text, options = {}) {
2
+ const issues = options.issues;
3
+ const strict = options.strict === true;
2
4
  const out = [];
3
5
  // Strip a leading UTF-8 BOM on the whole blob so the first line parses
4
6
  // cleanly; `trim()` handles BOM on subsequent lines through the same
5
7
  // codepath (empty/whitespace-only lines are skipped).
6
8
  const normalized = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
7
- for (const raw of normalized.split(/\r?\n/)) {
9
+ const lines = normalized.split(/\r?\n/);
10
+ for (let index = 0; index < lines.length; index += 1) {
11
+ const raw = lines[index] ?? "";
8
12
  const line = raw.trim();
9
13
  if (!line)
10
14
  continue;
15
+ const lineNumber = index + 1;
16
+ let parsed;
11
17
  try {
12
- const parsed = JSON.parse(line);
13
- const phase = parsed.phase;
14
- if (phase !== "red" && phase !== "green" && phase !== "refactor") {
18
+ parsed = JSON.parse(line);
19
+ }
20
+ catch (err) {
21
+ issues?.push({
22
+ lineNumber,
23
+ reason: `json-parse-failed: ${err instanceof Error ? err.message : String(err)}`,
24
+ rawLine: raw
25
+ });
26
+ continue;
27
+ }
28
+ const phase = parsed.phase;
29
+ if (phase !== "red" && phase !== "green" && phase !== "refactor") {
30
+ issues?.push({
31
+ lineNumber,
32
+ reason: `invalid-phase: ${JSON.stringify(parsed.phase)}`,
33
+ rawLine: raw
34
+ });
35
+ continue;
36
+ }
37
+ const runIdField = typeof parsed.runId === "string" ? parsed.runId : null;
38
+ const stageField = typeof parsed.stage === "string" ? parsed.stage : null;
39
+ const sliceField = typeof parsed.slice === "string" ? parsed.slice : null;
40
+ if (strict) {
41
+ const missing = [];
42
+ if (!runIdField)
43
+ missing.push("runId");
44
+ if (!stageField)
45
+ missing.push("stage");
46
+ if (!sliceField)
47
+ missing.push("slice");
48
+ if (missing.length > 0) {
49
+ issues?.push({
50
+ lineNumber,
51
+ reason: `missing-required-fields: ${missing.join(",")}`,
52
+ rawLine: raw
53
+ });
15
54
  continue;
16
55
  }
17
- const entry = {
18
- ts: typeof parsed.ts === "string" ? parsed.ts : "",
19
- runId: typeof parsed.runId === "string" ? parsed.runId : "active",
20
- stage: typeof parsed.stage === "string" ? parsed.stage : "tdd",
21
- slice: typeof parsed.slice === "string" ? parsed.slice : "S-unknown",
22
- phase,
23
- command: typeof parsed.command === "string" ? parsed.command : "",
24
- files: Array.isArray(parsed.files)
25
- ? parsed.files.filter((item) => typeof item === "string")
26
- : undefined,
27
- exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : undefined,
28
- note: typeof parsed.note === "string" ? parsed.note : undefined,
29
- acIds: Array.isArray(parsed.acIds)
30
- ? parsed.acIds
31
- .filter((item) => typeof item === "string" && item.length > 0)
32
- : undefined
33
- };
34
- out.push(entry);
35
- }
36
- catch {
37
- // skip malformed line
38
56
  }
57
+ const entry = {
58
+ ts: typeof parsed.ts === "string" ? parsed.ts : "",
59
+ runId: runIdField ?? "active",
60
+ stage: stageField ?? "tdd",
61
+ slice: sliceField ?? "S-unknown",
62
+ phase,
63
+ command: typeof parsed.command === "string" ? parsed.command : "",
64
+ files: Array.isArray(parsed.files)
65
+ ? parsed.files.filter((item) => typeof item === "string")
66
+ : undefined,
67
+ exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : undefined,
68
+ note: typeof parsed.note === "string" ? parsed.note : undefined,
69
+ acIds: Array.isArray(parsed.acIds)
70
+ ? parsed.acIds
71
+ .filter((item) => typeof item === "string" && item.length > 0)
72
+ : undefined
73
+ };
74
+ out.push(entry);
39
75
  }
40
76
  return out;
41
77
  }
@@ -123,8 +159,40 @@ export function validateTddCycleOrder(entries, options = {}) {
123
159
  sliceCount: bySlice.size
124
160
  };
125
161
  }
162
+ /**
163
+ * Canonical path normalization used by ALL TDD path-matching layers
164
+ * (cycle-log analysis, internal CLI `tdd-red-evidence`, and the
165
+ * inline runtime hook). Previously each layer had its own near-copy,
166
+ * which produced subtle drift (e.g. `./src/app.ts` vs `src/app.ts`
167
+ * differing between hook and CLI). Keep this function in one place
168
+ * so all callers agree.
169
+ */
170
+ export function normalizeTddPath(value) {
171
+ const trimmed = String(value ?? "").trim();
172
+ return trimmed
173
+ .replace(/\\/gu, "/")
174
+ .replace(/^\.\//u, "")
175
+ .toLowerCase();
176
+ }
177
+ // Legacy alias kept for local callers below.
126
178
  function normalizePath(value) {
127
- return value.replace(/\\/gu, "/").toLowerCase();
179
+ return normalizeTddPath(value);
180
+ }
181
+ /**
182
+ * Shared "does the recorded file path refer to the target" matcher.
183
+ * Uses canonical normalization plus the traditional `endsWith('/'+x)`
184
+ * rule so a recorded `src/app.ts` matches either `src/app.ts` or
185
+ * `subdir/src/app.ts` but NOT `src/app.test.ts`. All TDD path checks
186
+ * MUST go through this helper.
187
+ */
188
+ export function pathMatchesTarget(candidate, target) {
189
+ const normalizedCandidate = normalizeTddPath(candidate);
190
+ const normalizedTarget = normalizeTddPath(target);
191
+ if (normalizedCandidate.length === 0 || normalizedTarget.length === 0) {
192
+ return false;
193
+ }
194
+ return (normalizedCandidate === normalizedTarget ||
195
+ normalizedCandidate.endsWith(`/${normalizedTarget}`));
128
196
  }
129
197
  /**
130
198
  * Derive a lightweight Ralph Loop summary from parsed tdd-cycle-log entries.
@@ -197,7 +265,6 @@ export function computeRalphLoopStatus(entries, options = {}) {
197
265
  * `productionPath` for the active run.
198
266
  */
199
267
  export function hasFailingTestForPath(entries, productionPath, options = {}) {
200
- const normalizedTarget = normalizePath(productionPath);
201
268
  const filtered = options.runId
202
269
  ? entries.filter((entry) => entry.runId === options.runId)
203
270
  : entries;
@@ -208,10 +275,7 @@ export function hasFailingTestForPath(entries, productionPath, options = {}) {
208
275
  continue;
209
276
  if (!Array.isArray(entry.files) || entry.files.length === 0)
210
277
  continue;
211
- const hasMatch = entry.files.some((filePath) => {
212
- const normalized = normalizePath(filePath);
213
- return normalized === normalizedTarget || normalized.endsWith(`/${normalizedTarget}`);
214
- });
278
+ const hasMatch = entry.files.some((filePath) => pathMatchesTarget(filePath, productionPath));
215
279
  if (hasMatch) {
216
280
  return true;
217
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.18",
3
+ "version": "0.48.20",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {