cclaw-cli 0.48.17 → 0.48.19

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.
@@ -118,26 +118,54 @@ async function writeFileAtomic(filePath, content, options = {}) {
118
118
  "." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
119
119
  );
120
120
  await fs.writeFile(tempPath, content, { encoding: "utf8" });
121
- try {
122
- await fs.rename(tempPath, filePath);
123
- if (options.mode !== undefined) {
124
- await fs.chmod(filePath, options.mode).catch(() => undefined);
125
- }
126
- } catch (error) {
127
- const code = error && typeof error === "object" && "code" in error ? error.code : null;
128
- if (code === "EXDEV") {
129
- try {
130
- await fs.copyFile(tempPath, filePath);
131
- } finally {
132
- await fs.unlink(tempPath).catch(() => undefined);
133
- }
121
+ // Windows' fs.rename can fail transiently with EPERM/EBUSY/EACCES when the
122
+ // destination file is held open by another process (antivirus, indexer,
123
+ // or a sibling hook invocation racing on the same file). Retry with tiny
124
+ // backoff before falling back to copyFile.
125
+ const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
126
+ let attempt = 0;
127
+ const maxAttempts = 6;
128
+ while (true) {
129
+ try {
130
+ await fs.rename(tempPath, filePath);
134
131
  if (options.mode !== undefined) {
135
132
  await fs.chmod(filePath, options.mode).catch(() => undefined);
136
133
  }
137
134
  return;
135
+ } catch (error) {
136
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
137
+ if (code === "EXDEV") {
138
+ try {
139
+ await fs.copyFile(tempPath, filePath);
140
+ } finally {
141
+ await fs.unlink(tempPath).catch(() => undefined);
142
+ }
143
+ if (options.mode !== undefined) {
144
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
145
+ }
146
+ return;
147
+ }
148
+ if (renameRetryableCodes.has(code) && attempt < maxAttempts) {
149
+ attempt += 1;
150
+ await hookSleep(10 * attempt + Math.floor(Math.random() * 10));
151
+ continue;
152
+ }
153
+ if (renameRetryableCodes.has(code)) {
154
+ // Last-resort fallback: copy-then-unlink. Not atomic, but the
155
+ // directory lock around this call already serializes writers.
156
+ try {
157
+ await fs.copyFile(tempPath, filePath);
158
+ if (options.mode !== undefined) {
159
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
160
+ }
161
+ return;
162
+ } finally {
163
+ await fs.unlink(tempPath).catch(() => undefined);
164
+ }
165
+ }
166
+ await fs.unlink(tempPath).catch(() => undefined);
167
+ throw error;
138
168
  }
139
- await fs.unlink(tempPath).catch(() => undefined);
140
- throw error;
141
169
  }
142
170
  }
143
171
 
package/dist/fs-utils.js CHANGED
@@ -75,35 +75,67 @@ export async function writeFileSafe(filePath, content, options = {}) {
75
75
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
76
76
  const targetMode = options.mode;
77
77
  await fs.writeFile(tempPath, content, { encoding: "utf8", ...(targetMode !== undefined ? { mode: targetMode } : {}) });
78
- try {
79
- await fs.rename(tempPath, filePath);
80
- if (targetMode !== undefined) {
81
- await fs.chmod(filePath, targetMode).catch(() => undefined);
78
+ // On Windows, `fs.rename` can fail transiently with EPERM / EBUSY / EACCES
79
+ // when the target file is briefly held open by another process (antivirus,
80
+ // search indexer, or a concurrent cclaw hook). Retry with small backoff
81
+ // before falling back to a non-atomic copy + unlink.
82
+ const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
83
+ let attempt = 0;
84
+ const maxAttempts = 6;
85
+ // eslint-disable-next-line no-constant-condition
86
+ while (true) {
87
+ try {
88
+ await fs.rename(tempPath, filePath);
89
+ if (targetMode !== undefined) {
90
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
91
+ }
92
+ return;
82
93
  }
83
- }
84
- catch (error) {
85
- const code = error?.code;
86
- // `rename` fails with EXDEV when the temp file and target live on
87
- // different filesystems (container bind mounts, tmpfs + rootfs,
88
- // cross-volume setups). Fall back to copy + unlink so atomic writes
89
- // still work copyFile is not fully atomic but is the best we can
90
- // do across devices, and we remove the temp even if copy fails.
91
- if (code === "EXDEV") {
92
- try {
93
- await fs.copyFile(tempPath, filePath);
94
- if (targetMode !== undefined) {
95
- await fs.chmod(filePath, targetMode).catch(() => undefined);
94
+ catch (error) {
95
+ const code = error?.code;
96
+ // `rename` fails with EXDEV when the temp file and target live on
97
+ // different filesystems (container bind mounts, tmpfs + rootfs,
98
+ // cross-volume setups). Fall back to copy + unlink so atomic writes
99
+ // still work copyFile is not fully atomic but is the best we can
100
+ // do across devices, and we remove the temp even if copy fails.
101
+ if (code === "EXDEV") {
102
+ try {
103
+ await fs.copyFile(tempPath, filePath);
104
+ if (targetMode !== undefined) {
105
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
106
+ }
107
+ }
108
+ finally {
109
+ await fs.unlink(tempPath).catch(() => undefined);
96
110
  }
111
+ return;
97
112
  }
98
- finally {
99
- await fs.unlink(tempPath).catch(() => undefined);
113
+ if (code !== undefined && renameRetryableCodes.has(code) && attempt < maxAttempts) {
114
+ attempt += 1;
115
+ const waitMs = 10 * attempt + Math.floor(Math.random() * 10);
116
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
117
+ continue;
100
118
  }
101
- return;
119
+ if (code !== undefined && renameRetryableCodes.has(code)) {
120
+ // Last-resort fallback on Windows: copy-then-unlink. Not atomic,
121
+ // but the caller is expected to have serialized via a directory
122
+ // lock so this only loses atomicity under extreme contention.
123
+ try {
124
+ await fs.copyFile(tempPath, filePath);
125
+ if (targetMode !== undefined) {
126
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
127
+ }
128
+ return;
129
+ }
130
+ finally {
131
+ await fs.unlink(tempPath).catch(() => undefined);
132
+ }
133
+ }
134
+ // Other errors: try to clean up the temp to avoid littering the
135
+ // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
136
+ await fs.unlink(tempPath).catch(() => undefined);
137
+ throw error;
102
138
  }
103
- // Other errors: try to clean up the temp to avoid littering the
104
- // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
105
- await fs.unlink(tempPath).catch(() => undefined);
106
- throw error;
107
139
  }
108
140
  }
109
141
  export async function exists(filePath) {
@@ -89,14 +89,40 @@ function hasFailingAutoEvidenceForPath(entries, targetPath, options = {}) {
89
89
  export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
90
90
  const args = parseArgs(tokens);
91
91
  const flowState = await readFlowState(projectRoot).catch(() => null);
92
+ // Strict runId scoping: a previous implementation fell back to no
93
+ // filter when both `--runId` and `flowState.activeRunId` were missing,
94
+ // which let evidence rows from past runs satisfy the current check
95
+ // (false positive). Now: require an explicit or inferred runId or
96
+ // fail loud so the caller cannot silently inherit cross-run state.
92
97
  const effectiveRunId = args.runId ?? flowState?.activeRunId;
98
+ if (!effectiveRunId || effectiveRunId.trim().length === 0) {
99
+ const reason = "tdd-red-evidence: cannot scope check — no --runId provided and " +
100
+ "flow-state.json has no activeRunId. Pass --runId=<id> explicitly " +
101
+ "or run `cclaw doctor` to reconcile state.";
102
+ if (!args.quiet) {
103
+ io.stdout.write(`${JSON.stringify({
104
+ ok: false,
105
+ path: args.targetPath,
106
+ runId: null,
107
+ error: reason,
108
+ sources: { tddCycleLog: false, autoEvidence: false }
109
+ }, null, 2)}\n`);
110
+ }
111
+ else {
112
+ io.stderr.write(`${reason}\n`);
113
+ }
114
+ return 2;
115
+ }
93
116
  const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
94
117
  const autoEvidencePath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-red-evidence.jsonl");
95
118
  let cycleLogHasRed = false;
96
119
  let autoEvidenceHasRed = false;
97
120
  try {
98
121
  const raw = await fs.readFile(tddLogPath, "utf8");
99
- const entries = parseTddCycleLog(raw);
122
+ // Strict parse: drop malformed/underspecified rows rather than
123
+ // backfilling runId=active / stage=tdd defaults, which used to
124
+ // silently glue foreign entries to the current run.
125
+ const entries = parseTddCycleLog(raw, { strict: true });
100
126
  cycleLogHasRed = hasFailingTestForPath(entries, args.targetPath, {
101
127
  runId: effectiveRunId
102
128
  });
@@ -119,7 +145,7 @@ export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
119
145
  io.stdout.write(`${JSON.stringify({
120
146
  ok: hasRed,
121
147
  path: args.targetPath,
122
- runId: effectiveRunId ?? null,
148
+ runId: effectiveRunId,
123
149
  sources: {
124
150
  tddCycleLog: cycleLogHasRed,
125
151
  autoEvidence: autoEvidenceHasRed
@@ -22,7 +22,28 @@ 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;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.17",
3
+ "version": "0.48.19",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {