cclaw-cli 0.48.19 → 0.48.21

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.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Single source of truth for how /cc-next should treat Ralph Loop status.
3
+ *
4
+ * IMPORTANT: Ralph Loop is a **progress indicator + soft pre-advance nudge**,
5
+ * not a hard gate. Hard enforcement always flows through flow-state.json
6
+ * gates via `stage-complete.mjs`. Both the command contract and the skill
7
+ * document render this same paragraph to prevent drift — see
8
+ * `tests/e2e/next-command-ralph-loop-contract.test.ts`.
9
+ */
10
+ export declare const RALPH_LOOP_CONTRACT_MARKER = "ralph-loop-contract:v1";
11
+ export declare function ralphLoopContractSnippet(): string;
1
12
  /**
2
13
  * Command contract for /cc-next — the primary progression command.
3
14
  * Reads flow-state, starts the current stage if unfinished, or advances if all gates pass.
@@ -13,6 +13,34 @@ function delegationLogPathLine() {
13
13
  function reconciliationNoticesPathLine() {
14
14
  return `${RUNTIME_ROOT}/state/reconciliation-notices.json`;
15
15
  }
16
+ /**
17
+ * Single source of truth for how /cc-next should treat Ralph Loop status.
18
+ *
19
+ * IMPORTANT: Ralph Loop is a **progress indicator + soft pre-advance nudge**,
20
+ * not a hard gate. Hard enforcement always flows through flow-state.json
21
+ * gates via `stage-complete.mjs`. Both the command contract and the skill
22
+ * document render this same paragraph to prevent drift — see
23
+ * `tests/e2e/next-command-ralph-loop-contract.test.ts`.
24
+ */
25
+ export const RALPH_LOOP_CONTRACT_MARKER = "ralph-loop-contract:v1";
26
+ export function ralphLoopContractSnippet() {
27
+ return `**Ralph Loop (tdd only).** When \`currentStage === "tdd"\`, read
28
+ \`${RUNTIME_ROOT}/state/ralph-loop.json\` (refreshed on every session-start
29
+ while the flow is in tdd) as a **progress indicator**:
30
+
31
+ - \`loopIteration\` — running count of RED → GREEN cycles already landed.
32
+ - \`acClosed\` — distinct acceptance-criterion IDs closed by GREEN rows
33
+ (populated from \`acIds\` in \`tdd-cycle-log.jsonl\`).
34
+ - \`redOpenSlices\` — slices with an unsatisfied RED.
35
+
36
+ Ralph Loop is a **soft pre-advance nudge**, not a gate: do not advance
37
+ toward review while \`redOpenSlices\` is non-empty unless the user
38
+ explicitly defers a slice. Hard gate enforcement always flows through
39
+ \`flow-state.json\` gates via \`node .cclaw/hooks/stage-complete.mjs <stage>\`;
40
+ Ralph Loop fields never gate-check on their own.
41
+
42
+ <!-- ${RALPH_LOOP_CONTRACT_MARKER} -->`;
43
+ }
16
44
  /**
17
45
  * Command contract for /cc-next — the primary progression command.
18
46
  * Reads flow-state, starts the current stage if unfinished, or advances if all gates pass.
@@ -60,17 +88,8 @@ This is the only progression command the user needs to drive the entire flow. St
60
88
  → Load **\`${RUNTIME_ROOT}/skills/<skillFolder>/SKILL.md\`** and **\`${RUNTIME_ROOT}/commands/<currentStage>.md\`** for the current stage.
61
89
  → Execute that stage's protocol. The stage skill handles the full interaction including STOP points and gate tracking.
62
90
  → Stage completion must use \`node .cclaw/hooks/stage-complete.mjs <currentStage>\` (canonical), which validates delegations + gate evidence before mutating \`flow-state.json\`.
63
- → **Ralph Loop (tdd only).** When \`currentStage === "tdd"\`, also read
64
- \`${RUNTIME_ROOT}/state/ralph-loop.json\` (refreshed on every session-start
65
- while the flow is in tdd). Use it as a ground-truth progress indicator:
66
- - \`loopIteration\` tells you how many RED → GREEN cycles already landed.
67
- - \`acClosed\` lists the distinct acceptance-criterion IDs a GREEN row has
68
- closed so far — if your plan tasks map to ACs, this is the "tasks
69
- remaining" signal without needing a separate counter.
70
- - \`redOpenSlices\` is the set of slices with an unsatisfied RED. Do not
71
- advance to review while this is non-empty.
72
- - Stage advancement to \`review\` still requires the normal gates in
73
- \`flow-state.json\`; Ralph Loop status is a soft nudge, not a gate.
91
+
92
+ ${ralphLoopContractSnippet()}
74
93
 
75
94
  ### Path B: Current stage IS complete (all gates passed, all delegations satisfied)
76
95
 
@@ -208,14 +227,7 @@ Load the current stage's skill and command contract:
208
227
 
209
228
  Execute the stage protocol. The stage skill handles interaction, STOP points, gate tracking, and stage completion via \`node .cclaw/hooks/stage-complete.mjs <stage>\` (canonical flow-state mutation path).
210
229
 
211
- **Ralph Loop (tdd only).** When the current stage is \`tdd\`, pair the
212
- normal gate-evidence view with \`${RUNTIME_ROOT}/state/ralph-loop.json\`:
213
- \`loopIteration\` is the running count of RED → GREEN cycles,
214
- \`acClosed\` lists distinct acceptance-criterion IDs already closed by
215
- GREEN rows (populated from \`acIds\` in \`tdd-cycle-log.jsonl\`), and
216
- \`redOpenSlices\` is the "tasks remaining" indicator. Advance only when
217
- every planned slice is in \`acClosed\` (or explicitly deferred) and
218
- \`redOpenSlices\` is empty.
230
+ ${ralphLoopContractSnippet()}
219
231
 
220
232
  Special-case for review: if \`review_criticals_resolved\` is in \`blocked\`, route to rework instead of looping review forever — recommend \`/cc-ops rewind tdd "review_blocked_by_critical"\`.
221
233
 
@@ -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;
@@ -47,6 +47,23 @@ export declare function parseTddCycleLog(text: string, options?: ParseTddCycleLo
47
47
  export declare function validateTddCycleOrder(entries: TddCycleEntry[], options?: {
48
48
  runId?: string;
49
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;
50
67
  export interface RalphLoopSliceState {
51
68
  slice: string;
52
69
  redCount: number;
package/dist/tdd-cycle.js CHANGED
@@ -159,8 +159,40 @@ export function validateTddCycleOrder(entries, options = {}) {
159
159
  sliceCount: bySlice.size
160
160
  };
161
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.
162
178
  function normalizePath(value) {
163
- 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}`));
164
196
  }
165
197
  /**
166
198
  * Derive a lightweight Ralph Loop summary from parsed tdd-cycle-log entries.
@@ -233,7 +265,6 @@ export function computeRalphLoopStatus(entries, options = {}) {
233
265
  * `productionPath` for the active run.
234
266
  */
235
267
  export function hasFailingTestForPath(entries, productionPath, options = {}) {
236
- const normalizedTarget = normalizePath(productionPath);
237
268
  const filtered = options.runId
238
269
  ? entries.filter((entry) => entry.runId === options.runId)
239
270
  : entries;
@@ -244,10 +275,7 @@ export function hasFailingTestForPath(entries, productionPath, options = {}) {
244
275
  continue;
245
276
  if (!Array.isArray(entry.files) || entry.files.length === 0)
246
277
  continue;
247
- const hasMatch = entry.files.some((filePath) => {
248
- const normalized = normalizePath(filePath);
249
- return normalized === normalizedTarget || normalized.endsWith(`/${normalizedTarget}`);
250
- });
278
+ const hasMatch = entry.files.some((filePath) => pathMatchesTarget(filePath, productionPath));
251
279
  if (hasMatch) {
252
280
  return true;
253
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.19",
3
+ "version": "0.48.21",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {