cclaw-cli 0.48.22 → 0.48.24

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.
@@ -103,11 +103,20 @@ async function withDirectoryLockInline(lockPath, fn, options = {}) {
103
103
  }
104
104
  try {
105
105
  const stat = await fs.stat(lockPath);
106
+ if (!stat.isDirectory()) {
107
+ throw new Error("Lock path exists but is not a directory: " + lockPath);
108
+ }
106
109
  if (Date.now() - stat.mtimeMs > staleAfterMs) {
107
110
  await fs.rm(lockPath, { recursive: true, force: true });
108
111
  continue;
109
112
  }
110
- } catch {
113
+ } catch (statError) {
114
+ if (
115
+ statError instanceof Error &&
116
+ statError.message.startsWith("Lock path exists but is not a directory")
117
+ ) {
118
+ throw statError;
119
+ }
111
120
  // lock vanished between retries
112
121
  }
113
122
  await hookSleep(retryDelayMs);
@@ -254,6 +263,24 @@ async function readTextFile(filePath, fallback = "") {
254
263
  }
255
264
  }
256
265
 
266
+ // CLI-compatible knowledge lock. Must match
267
+ // src/knowledge-store.ts::knowledgeLockPath exactly so the hook and the
268
+ // CLI serialize on the same mutex when reading / appending
269
+ // knowledge.jsonl. Drift here re-introduces the race we just closed.
270
+ function knowledgeLockPathInline(root) {
271
+ return path.join(root, RUNTIME_ROOT, "state", ".knowledge.lock");
272
+ }
273
+
274
+ async function readTextFileLocked(lockPath, filePath, fallback = "") {
275
+ return withDirectoryLockInline(lockPath, async () => {
276
+ try {
277
+ return await fs.readFile(filePath, "utf8");
278
+ } catch {
279
+ return fallback;
280
+ }
281
+ });
282
+ }
283
+
257
284
  async function appendJsonLine(filePath, value) {
258
285
  const payload = JSON.stringify(value) + "\\n";
259
286
  await withDirectoryLockInline(lockPathFor(filePath), async () => {
@@ -904,10 +931,16 @@ async function handleSessionStart(runtime) {
904
931
  const sessionDigest = (await readTextFile(sessionDigestFile, "")).trim();
905
932
  const activitySummary = await readRecentActivityLines(activityFile);
906
933
  const contextWarning = await readLatestContextWarningLine(contextWarningsFile);
907
- // Read knowledge.jsonl exactly once per session-start; both the digest
908
- // and compound-readiness derive from the same snapshot.
934
+ // Read knowledge.jsonl exactly once per session-start while holding the
935
+ // SAME lock CLI writers acquire in \`appendKnowledge\`. Guarantees we never
936
+ // see a partial (mid-write) snapshot. Both the digest and
937
+ // compound-readiness derive from this single read.
909
938
  const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
910
- const knowledgeRaw = await readTextFile(knowledgeFilePath, "");
939
+ const knowledgeRaw = await readTextFileLocked(
940
+ knowledgeLockPathInline(runtime.root),
941
+ knowledgeFilePath,
942
+ ""
943
+ );
911
944
  const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
912
945
 
913
946
  // Refresh Ralph Loop status each session-start so /cc-next and the model
@@ -925,8 +958,16 @@ async function handleSessionStart(runtime) {
925
958
  ", slices=" + String(ralphStatus.sliceCount) +
926
959
  ", acClosed=" + String(ralphStatus.acClosed.length) +
927
960
  ", redOpen=" + redOpen;
928
- } catch (_err) {
929
- // best-effort — a malformed cycle log should never break session-start.
961
+ } catch (err) {
962
+ // Best-effort — a malformed cycle log should never break
963
+ // session-start. But we DO leave a breadcrumb in
964
+ // hook-errors.jsonl so \`cclaw doctor\` can surface chronic
965
+ // failures (previously this was a silent swallow).
966
+ await recordHookError(
967
+ runtime.root,
968
+ "session-start:ralph-loop",
969
+ err instanceof Error ? err.message : String(err)
970
+ );
930
971
  }
931
972
  }
932
973
 
@@ -952,8 +993,16 @@ async function handleSessionStart(runtime) {
952
993
  ", ready=" + String(readiness.readyCount) + criticalSuffix;
953
994
  }
954
995
  }
955
- } catch (_err) {
956
- // best-effort — a malformed knowledge.jsonl must never break session-start.
996
+ } catch (err) {
997
+ // Best-effort — a malformed knowledge.jsonl must never break
998
+ // session-start. But we DO leave a breadcrumb in
999
+ // hook-errors.jsonl so config/IO problems become visible in
1000
+ // \`cclaw doctor\` instead of silently degrading readiness output.
1001
+ await recordHookError(
1002
+ runtime.root,
1003
+ "session-start:compound-readiness",
1004
+ err instanceof Error ? err.message : String(err)
1005
+ );
957
1006
  }
958
1007
 
959
1008
  const suggestionMemory = toObject(await readJsonFile(suggestionMemoryFile, {})) || {};
@@ -85,7 +85,7 @@ const RULES = [
85
85
  }
86
86
  },
87
87
  {
88
- test: /^(meta_skill:|protocol:|stage_skill:|context_mode:|reference:)/,
88
+ test: /^(meta_skill:|protocol:|stage_skill:|context_mode:)/,
89
89
  metadata: {
90
90
  severity: "error",
91
91
  summary: "Routing skill and protocol integrity check.",
@@ -93,6 +93,21 @@ const RULES = [
93
93
  docRef: ref("harness-and-routing.md")
94
94
  }
95
95
  },
96
+ {
97
+ // `reference:*` checks (flow-map.md and similar overview documents)
98
+ // are useful to detect drift from the generated baseline, but they
99
+ // document the surface rather than gate it. A missing section here
100
+ // means the map is out of date, not that a runtime contract is
101
+ // broken — so they report as a warning instead of hard-failing
102
+ // doctor / CI. `cclaw sync` rewrites the file.
103
+ test: /^reference:/,
104
+ metadata: {
105
+ severity: "warning",
106
+ summary: "Reference/overview doc integrity (non-blocking).",
107
+ fix: "Run `cclaw sync` to regenerate the reference doc from the canonical source.",
108
+ docRef: ref("harness-and-routing.md")
109
+ }
110
+ },
96
111
  {
97
112
  test: /^delegation:/,
98
113
  metadata: {
package/dist/fs-utils.js CHANGED
@@ -42,12 +42,24 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
42
42
  }
43
43
  try {
44
44
  const stat = await fs.stat(lockPath);
45
+ if (!stat.isDirectory()) {
46
+ // A non-directory lives at the lock path (e.g. a stray file).
47
+ // Retrying mkdir() will keep returning EEXIST forever, and no
48
+ // other cclaw process is holding the lock - the path is simply
49
+ // unusable. Fail loudly rather than burning the full retry
50
+ // budget, so the caller sees a deterministic error.
51
+ throw new Error(`Lock path exists but is not a directory: ${lockPath}`);
52
+ }
45
53
  if (Date.now() - stat.mtimeMs > staleAfterMs) {
46
54
  await fs.rm(lockPath, { recursive: true, force: true });
47
55
  continue;
48
56
  }
49
57
  }
50
- catch {
58
+ catch (statError) {
59
+ if (statError instanceof Error &&
60
+ statError.message.startsWith("Lock path exists but is not a directory")) {
61
+ throw statError;
62
+ }
51
63
  // Lock directory disappeared between retries.
52
64
  }
53
65
  await sleep(retryDelayMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.22",
3
+ "version": "0.48.24",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {