cclaw-cli 0.48.15 → 0.48.17

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,6 +118,11 @@ Hook-driven guards respect the \`strictness\` field in
118
118
 
119
119
  Override per-session with \`CCLAW_STRICTNESS=advisory|strict\`.
120
120
 
121
+ Hook wiring itself comes from a **single manifest** (\`src/content/hook-manifest.ts\`):
122
+ the per-harness documents at \`.claude/hooks/hooks.json\`, \`.cursor/hooks.json\`,
123
+ \`.codex/hooks.json\` are all derived from it. Inspect the live bindings with
124
+ \`cclaw internal hook-manifest\` (add \`--json\` for machine-readable output).
125
+
121
126
  ## When in doubt
122
127
 
123
128
  1. Read \`${RUNTIME_ROOT}/state/flow-state.json\` to know where you are.
@@ -1,4 +1,9 @@
1
1
  import type { HarnessId } from "../types.js";
2
- export declare const HOOK_SEMANTIC_EVENTS: readonly ["session_rehydrate", "pre_tool_prompt_guard", "pre_tool_workflow_guard", "post_tool_context_monitor", "stop_checkpoint", "precompact_digest"];
3
- export type HookSemanticEvent = (typeof HOOK_SEMANTIC_EVENTS)[number];
2
+ import { type HookSemanticEvent } from "./hook-manifest.js";
3
+ export { HOOK_SEMANTIC_EVENTS, type HookSemanticEvent } from "./hook-manifest.js";
4
+ /**
5
+ * Public semantic coverage map derived from `HOOK_MANIFEST` for
6
+ * claude/cursor/codex, plus the static OpenCode descriptor. Consumers
7
+ * should treat this as read-only.
8
+ */
4
9
  export declare const HOOK_EVENTS_BY_HARNESS: Record<HarnessId, Partial<Record<HookSemanticEvent, string>>>;
@@ -1,47 +1,31 @@
1
- export const HOOK_SEMANTIC_EVENTS = [
2
- "session_rehydrate",
3
- "pre_tool_prompt_guard",
4
- "pre_tool_workflow_guard",
5
- "post_tool_context_monitor",
6
- "stop_checkpoint",
7
- "precompact_digest"
8
- ];
9
- export const HOOK_EVENTS_BY_HARNESS = {
10
- claude: {
11
- session_rehydrate: "SessionStart matcher startup|resume|clear|compact",
12
- pre_tool_prompt_guard: "PreToolUse -> prompt-guard",
13
- pre_tool_workflow_guard: "PreToolUse -> workflow-guard",
14
- post_tool_context_monitor: "PostToolUse -> context-monitor",
15
- stop_checkpoint: "Stop -> stop-checkpoint",
16
- precompact_digest: "PreCompact -> pre-compact"
17
- },
18
- cursor: {
19
- session_rehydrate: "sessionStart/sessionResume/sessionClear/sessionCompact",
20
- pre_tool_prompt_guard: "preToolUse -> prompt-guard",
21
- pre_tool_workflow_guard: "preToolUse -> workflow-guard",
22
- post_tool_context_monitor: "postToolUse -> context-monitor",
23
- stop_checkpoint: "stop -> stop-checkpoint",
24
- precompact_digest: "sessionCompact -> pre-compact"
25
- },
26
- opencode: {
27
- session_rehydrate: "plugin event handlers + transform rehydration",
28
- pre_tool_prompt_guard: "plugin tool.execute.before -> prompt-guard",
29
- pre_tool_workflow_guard: "plugin tool.execute.before -> workflow-guard",
30
- post_tool_context_monitor: "plugin tool.execute.after -> context-monitor",
31
- stop_checkpoint: "plugin session.idle -> stop-checkpoint",
32
- precompact_digest: "plugin session.compacted -> pre-compact"
33
- },
34
- codex: {
35
- // Codex CLI v0.114+ exposes lifecycle hooks via `.codex/hooks.json`,
36
- // gated by `[features] codex_hooks = true` in `~/.codex/config.toml`.
37
- // SessionStart, Stop, and UserPromptSubmit fire for every turn;
38
- // PreToolUse/PostToolUse are **Bash-only** (Write/Edit/WebSearch/MCP
39
- // calls do not trigger them). `precompact_digest` is unmapped —
40
- // Codex has no PreCompact event; cclaw covers it via `/cc-ops retro`.
41
- session_rehydrate: "SessionStart matcher startup|resume",
42
- pre_tool_prompt_guard: "PreToolUse matcher Bash -> prompt-guard (plus UserPromptSubmit for non-Bash prompts)",
43
- pre_tool_workflow_guard: "PreToolUse matcher Bash -> workflow-guard (Bash-only)",
44
- post_tool_context_monitor: "PostToolUse matcher Bash -> context-monitor (Bash-only)",
45
- stop_checkpoint: "Stop -> stop-checkpoint"
46
- }
1
+ import { HOOK_MANIFEST_HARNESSES, semanticEventCoverage } from "./hook-manifest.js";
2
+ export { HOOK_SEMANTIC_EVENTS } from "./hook-manifest.js";
3
+ function isManifestHarness(value) {
4
+ return HOOK_MANIFEST_HARNESSES.includes(value);
5
+ }
6
+ /**
7
+ * OpenCode is covered by the inline plugin (`opencode-plugin.ts`), not
8
+ * by the generated `run-hook.mjs` dispatcher. We keep its semantic
9
+ * coverage table here as an explicit descriptor, since it does not
10
+ * flow through the hook manifest.
11
+ */
12
+ const OPENCODE_SEMANTIC_COVERAGE = {
13
+ session_rehydrate: "plugin event handlers + transform rehydration",
14
+ pre_tool_prompt_guard: "plugin tool.execute.before -> prompt-guard",
15
+ pre_tool_workflow_guard: "plugin tool.execute.before -> workflow-guard",
16
+ post_tool_context_monitor: "plugin tool.execute.after -> context-monitor",
17
+ stop_checkpoint: "plugin session.idle -> stop-checkpoint",
18
+ precompact_digest: "plugin session.compacted -> pre-compact"
47
19
  };
20
+ /**
21
+ * Public semantic coverage map derived from `HOOK_MANIFEST` for
22
+ * claude/cursor/codex, plus the static OpenCode descriptor. Consumers
23
+ * should treat this as read-only.
24
+ */
25
+ export const HOOK_EVENTS_BY_HARNESS = Object.freeze({
26
+ claude: semanticEventCoverage("claude"),
27
+ cursor: semanticEventCoverage("cursor"),
28
+ codex: semanticEventCoverage("codex"),
29
+ opencode: OPENCODE_SEMANTIC_COVERAGE
30
+ });
31
+ void isManifestHarness;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Canonical operational manifest for cclaw hooks.
3
+ *
4
+ * This is the **single source of truth** for:
5
+ *
6
+ * - the per-harness JSON generators in `./observe.ts`
7
+ * (claude/cursor/codex hook documents),
8
+ * - the semantic coverage map in `./hook-events.ts` (docs + doctor),
9
+ * - the `requiredEvents` list embedded in `src/hook-schemas/*.v1.json`
10
+ * (enforced by a parity test).
11
+ *
12
+ * When adding a new hook handler or rerouting an existing one, edit
13
+ * this file and let the downstream modules rebuild from it. Never
14
+ * hard-code a `SessionStart`, `PreToolUse`, etc. mapping outside of
15
+ * this manifest.
16
+ *
17
+ * OpenCode is deliberately out of scope here: its plugin
18
+ * (`opencode-plugin.ts`) implements the handlers inline rather than
19
+ * dispatching through `run-hook.mjs`, so its coverage is tracked
20
+ * separately in `HOOK_EVENTS_BY_HARNESS`.
21
+ */
22
+ export declare const HOOK_MANIFEST_HARNESSES: readonly ["claude", "cursor", "codex"];
23
+ export type HookManifestHarness = (typeof HOOK_MANIFEST_HARNESSES)[number];
24
+ export declare const HOOK_HANDLERS: readonly ["session-start", "prompt-guard", "workflow-guard", "context-monitor", "stop-checkpoint", "pre-compact", "verify-current-state"];
25
+ export type HookHandlerId = (typeof HOOK_HANDLERS)[number];
26
+ export interface HookBinding {
27
+ /**
28
+ * Harness-native event name (exact string; PascalCase for
29
+ * claude/codex, camelCase for cursor). Do not normalize casing.
30
+ */
31
+ event: string;
32
+ matcher?: string;
33
+ timeout?: number;
34
+ /**
35
+ * Within a single (harness, event) group, entries are sorted by
36
+ * `priority` ASC, ties broken by manifest-declaration order. Use
37
+ * this to express "this handler must run BEFORE/AFTER that handler
38
+ * on the same event" (e.g. pre-compact must run before session-start
39
+ * on cursor `sessionCompact`). Default `0`.
40
+ */
41
+ priority?: number;
42
+ }
43
+ export interface HookHandlerSpec {
44
+ handler: HookHandlerId;
45
+ description: string;
46
+ /**
47
+ * Semantic event id used by `HOOK_EVENTS_BY_HARNESS` / docs.
48
+ * `null` means this handler contributes no semantic coverage row
49
+ * (e.g. `verify-current-state` on codex is a supplementary guard,
50
+ * not a top-level semantic event).
51
+ */
52
+ semantic: HookSemanticEvent | null;
53
+ bindings: Partial<Record<HookManifestHarness, HookBinding[]>>;
54
+ }
55
+ export declare const HOOK_SEMANTIC_EVENTS: readonly ["session_rehydrate", "pre_tool_prompt_guard", "pre_tool_workflow_guard", "post_tool_context_monitor", "stop_checkpoint", "precompact_digest"];
56
+ export type HookSemanticEvent = (typeof HOOK_SEMANTIC_EVENTS)[number];
57
+ export declare const HOOK_MANIFEST: readonly HookHandlerSpec[];
58
+ export interface EventGroup {
59
+ event: string;
60
+ /**
61
+ * Entries sorted by (priority ASC, declaration order). Default
62
+ * priority is 0. Stable — ties preserve manifest order.
63
+ */
64
+ entries: Array<{
65
+ handler: HookHandlerId;
66
+ matcher?: string;
67
+ timeout?: number;
68
+ }>;
69
+ }
70
+ /**
71
+ * Group manifest bindings by harness-native event name. This is the
72
+ * core projection that observe.ts generators consume to emit the
73
+ * harness-specific JSON document.
74
+ */
75
+ export declare function groupBindingsByEvent(harness: HookManifestHarness): EventGroup[];
76
+ /** Distinct harness-native event names covered by the manifest. */
77
+ export declare function requiredEventsFor(harness: HookManifestHarness): string[];
78
+ /**
79
+ * Human-readable per-harness semantic coverage used by docs and by
80
+ * the doctor's `harness-gaps.json` synthesis.
81
+ */
82
+ export declare function semanticEventCoverage(harness: HookManifestHarness): Partial<Record<HookSemanticEvent, string>>;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Canonical operational manifest for cclaw hooks.
3
+ *
4
+ * This is the **single source of truth** for:
5
+ *
6
+ * - the per-harness JSON generators in `./observe.ts`
7
+ * (claude/cursor/codex hook documents),
8
+ * - the semantic coverage map in `./hook-events.ts` (docs + doctor),
9
+ * - the `requiredEvents` list embedded in `src/hook-schemas/*.v1.json`
10
+ * (enforced by a parity test).
11
+ *
12
+ * When adding a new hook handler or rerouting an existing one, edit
13
+ * this file and let the downstream modules rebuild from it. Never
14
+ * hard-code a `SessionStart`, `PreToolUse`, etc. mapping outside of
15
+ * this manifest.
16
+ *
17
+ * OpenCode is deliberately out of scope here: its plugin
18
+ * (`opencode-plugin.ts`) implements the handlers inline rather than
19
+ * dispatching through `run-hook.mjs`, so its coverage is tracked
20
+ * separately in `HOOK_EVENTS_BY_HARNESS`.
21
+ */
22
+ export const HOOK_MANIFEST_HARNESSES = ["claude", "cursor", "codex"];
23
+ export const HOOK_HANDLERS = [
24
+ "session-start",
25
+ "prompt-guard",
26
+ "workflow-guard",
27
+ "context-monitor",
28
+ "stop-checkpoint",
29
+ "pre-compact",
30
+ "verify-current-state"
31
+ ];
32
+ export const HOOK_SEMANTIC_EVENTS = [
33
+ "session_rehydrate",
34
+ "pre_tool_prompt_guard",
35
+ "pre_tool_workflow_guard",
36
+ "post_tool_context_monitor",
37
+ "stop_checkpoint",
38
+ "precompact_digest"
39
+ ];
40
+ export const HOOK_MANIFEST = [
41
+ {
42
+ handler: "session-start",
43
+ description: "Rehydrate flow state, refresh Ralph Loop + compound readiness, emit bootstrap digest.",
44
+ semantic: "session_rehydrate",
45
+ bindings: {
46
+ claude: [{ event: "SessionStart", matcher: "startup|resume|clear|compact" }],
47
+ cursor: [
48
+ { event: "sessionStart" },
49
+ { event: "sessionResume" },
50
+ { event: "sessionClear" },
51
+ { event: "sessionCompact" }
52
+ ],
53
+ codex: [{ event: "SessionStart", matcher: "startup|resume" }]
54
+ }
55
+ },
56
+ {
57
+ handler: "prompt-guard",
58
+ description: "Stage-aware prompt gate (iron-laws + strictness).",
59
+ semantic: "pre_tool_prompt_guard",
60
+ bindings: {
61
+ claude: [{ event: "PreToolUse", matcher: "*" }],
62
+ cursor: [{ event: "preToolUse", matcher: "*" }],
63
+ codex: [
64
+ { event: "UserPromptSubmit" },
65
+ { event: "PreToolUse", matcher: "Bash|bash" }
66
+ ]
67
+ }
68
+ },
69
+ {
70
+ handler: "workflow-guard",
71
+ description: "TDD and workflow gate on Write/Edit/Bash style tool invocations.",
72
+ semantic: "pre_tool_workflow_guard",
73
+ bindings: {
74
+ claude: [{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash" }],
75
+ cursor: [{ event: "preToolUse", matcher: "*" }],
76
+ codex: [
77
+ { event: "UserPromptSubmit" },
78
+ { event: "PreToolUse", matcher: "Bash|bash" }
79
+ ]
80
+ }
81
+ },
82
+ {
83
+ handler: "context-monitor",
84
+ description: "Post-tool context usage + stage signal monitor.",
85
+ semantic: "post_tool_context_monitor",
86
+ bindings: {
87
+ claude: [{ event: "PostToolUse", matcher: "*" }],
88
+ cursor: [{ event: "postToolUse", matcher: "*" }],
89
+ codex: [{ event: "PostToolUse", matcher: "Bash|bash" }]
90
+ }
91
+ },
92
+ {
93
+ handler: "stop-checkpoint",
94
+ description: "Persist checkpoint with stage + run context on session stop.",
95
+ semantic: "stop_checkpoint",
96
+ bindings: {
97
+ claude: [{ event: "Stop", timeout: 10 }],
98
+ cursor: [{ event: "stop", timeout: 10 }],
99
+ codex: [{ event: "Stop", timeout: 10 }]
100
+ }
101
+ },
102
+ {
103
+ handler: "pre-compact",
104
+ description: "Write pre-compact digest (Claude+Cursor have a native event; Codex has no PreCompact — covered by `/cc-ops retro`).",
105
+ semantic: "precompact_digest",
106
+ bindings: {
107
+ claude: [{ event: "PreCompact", matcher: "manual|auto", timeout: 10 }],
108
+ // pre-compact must capture the digest BEFORE session-start
109
+ // rehydrates on cursor `sessionCompact`.
110
+ cursor: [{ event: "sessionCompact", priority: -10 }]
111
+ }
112
+ },
113
+ {
114
+ handler: "verify-current-state",
115
+ description: "Supplementary codex guard that runs on UserPromptSubmit to assert the live state matches the flow.",
116
+ semantic: null,
117
+ bindings: {
118
+ codex: [{ event: "UserPromptSubmit" }]
119
+ }
120
+ }
121
+ ];
122
+ /** Sanity: every harness in HOOK_MANIFEST_HARNESSES must be a HarnessId. */
123
+ const _harnessIdCheck = HOOK_MANIFEST_HARNESSES;
124
+ void _harnessIdCheck;
125
+ /**
126
+ * Group manifest bindings by harness-native event name. This is the
127
+ * core projection that observe.ts generators consume to emit the
128
+ * harness-specific JSON document.
129
+ */
130
+ export function groupBindingsByEvent(harness) {
131
+ const order = [];
132
+ const byEvent = new Map();
133
+ let seq = 0;
134
+ for (const spec of HOOK_MANIFEST) {
135
+ const bindings = spec.bindings[harness];
136
+ if (!bindings)
137
+ continue;
138
+ for (const binding of bindings) {
139
+ let group = byEvent.get(binding.event);
140
+ if (!group) {
141
+ group = { event: binding.event, entries: [] };
142
+ byEvent.set(binding.event, group);
143
+ order.push(binding.event);
144
+ }
145
+ group.entries.push({
146
+ handler: spec.handler,
147
+ ...(binding.matcher !== undefined ? { matcher: binding.matcher } : {}),
148
+ ...(binding.timeout !== undefined ? { timeout: binding.timeout } : {}),
149
+ priority: binding.priority ?? 0,
150
+ seq: seq++
151
+ });
152
+ }
153
+ }
154
+ return order.map((event) => {
155
+ const group = byEvent.get(event);
156
+ const sorted = [...group.entries].sort((a, b) => {
157
+ if (a.priority !== b.priority)
158
+ return a.priority - b.priority;
159
+ return a.seq - b.seq;
160
+ });
161
+ return {
162
+ event: group.event,
163
+ entries: sorted.map(({ priority: _p, seq: _s, ...entry }) => entry)
164
+ };
165
+ });
166
+ }
167
+ /** Distinct harness-native event names covered by the manifest. */
168
+ export function requiredEventsFor(harness) {
169
+ const seen = new Set();
170
+ const ordered = [];
171
+ for (const spec of HOOK_MANIFEST) {
172
+ for (const binding of spec.bindings[harness] ?? []) {
173
+ if (seen.has(binding.event))
174
+ continue;
175
+ seen.add(binding.event);
176
+ ordered.push(binding.event);
177
+ }
178
+ }
179
+ return ordered;
180
+ }
181
+ /**
182
+ * Human-readable per-harness semantic coverage used by docs and by
183
+ * the doctor's `harness-gaps.json` synthesis.
184
+ */
185
+ export function semanticEventCoverage(harness) {
186
+ const out = {};
187
+ for (const spec of HOOK_MANIFEST) {
188
+ if (spec.semantic === null)
189
+ continue;
190
+ const bindings = spec.bindings[harness];
191
+ if (!bindings || bindings.length === 0)
192
+ continue;
193
+ out[spec.semantic] = describeBindings(bindings);
194
+ }
195
+ return out;
196
+ }
197
+ function describeBindings(bindings) {
198
+ return bindings
199
+ .map((binding) => {
200
+ const pieces = [binding.event];
201
+ if (binding.matcher)
202
+ pieces.push(`matcher=${binding.matcher}`);
203
+ if (binding.timeout)
204
+ pieces.push(`timeout=${binding.timeout}s`);
205
+ return pieces.join(" ");
206
+ })
207
+ .join(" | ");
208
+ }
@@ -53,19 +53,145 @@ function safeParseJson(raw, fallback = {}) {
53
53
  }
54
54
  }
55
55
 
56
- async function readJsonFile(filePath, fallback = {}) {
56
+ // === atomic/locked state I/O =========================================
57
+ //
58
+ // The generated hook script runs OUTSIDE the cclaw CLI process, so it
59
+ // cannot import \`fs-utils.ts\`. These helpers mirror \`writeFileSafe\` and
60
+ // \`withDirectoryLock\` just enough to keep hook-owned state files
61
+ // atomic and free of interleaved concurrent writes.
62
+
63
+ function hookSleep(ms) {
64
+ return new Promise((resolve) => setTimeout(resolve, ms));
65
+ }
66
+
67
+ async function withDirectoryLockInline(lockPath, fn, options = {}) {
68
+ const retries = Number.isFinite(options.retries) ? options.retries : 200;
69
+ const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 20;
70
+ const staleAfterMs = Number.isFinite(options.staleAfterMs) ? options.staleAfterMs : 60000;
71
+ try {
72
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
73
+ } catch {
74
+ // parent may already exist
75
+ }
76
+ let acquired = false;
77
+ let lastError = null;
78
+ for (let attempt = 0; attempt < retries; attempt += 1) {
79
+ try {
80
+ await fs.mkdir(lockPath);
81
+ acquired = true;
82
+ break;
83
+ } catch (error) {
84
+ lastError = error;
85
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
86
+ if (code !== "EEXIST") {
87
+ throw error;
88
+ }
89
+ try {
90
+ const stat = await fs.stat(lockPath);
91
+ if (Date.now() - stat.mtimeMs > staleAfterMs) {
92
+ await fs.rm(lockPath, { recursive: true, force: true });
93
+ continue;
94
+ }
95
+ } catch {
96
+ // lock vanished between retries
97
+ }
98
+ await hookSleep(retryDelayMs);
99
+ }
100
+ }
101
+ if (!acquired) {
102
+ const details = lastError instanceof Error ? lastError.message : String(lastError);
103
+ throw new Error(
104
+ "cclaw hook: failed to acquire lock " + lockPath + " (attempts=" + retries + ", lastError=" + details + ")"
105
+ );
106
+ }
107
+ try {
108
+ return await fn();
109
+ } finally {
110
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
111
+ }
112
+ }
113
+
114
+ async function writeFileAtomic(filePath, content, options = {}) {
115
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
116
+ const tempPath = path.join(
117
+ path.dirname(filePath),
118
+ "." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
119
+ );
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
+ }
134
+ if (options.mode !== undefined) {
135
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
136
+ }
137
+ return;
138
+ }
139
+ await fs.unlink(tempPath).catch(() => undefined);
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ function lockPathFor(filePath) {
145
+ return filePath + ".lock";
146
+ }
147
+
148
+ async function recordHookError(root, stage, detail) {
149
+ try {
150
+ const errorsPath = path.join(root, RUNTIME_ROOT, "state", "hook-errors.jsonl");
151
+ await fs.mkdir(path.dirname(errorsPath), { recursive: true });
152
+ const payload = JSON.stringify({
153
+ ts: new Date().toISOString(),
154
+ stage: typeof stage === "string" ? stage : "unknown",
155
+ detail: typeof detail === "string" ? detail : String(detail)
156
+ });
157
+ await fs.appendFile(errorsPath, payload + "\\n", "utf8");
158
+ } catch {
159
+ // diagnostics must never cascade
160
+ }
161
+ }
162
+
163
+ async function readJsonFile(filePath, fallback = {}, options = {}) {
57
164
  try {
58
165
  const raw = await fs.readFile(filePath, "utf8");
59
- return safeParseJson(raw, fallback);
166
+ if (typeof raw !== "string" || raw.trim().length === 0) {
167
+ return fallback;
168
+ }
169
+ try {
170
+ const parsed = JSON.parse(raw);
171
+ return parsed === undefined ? fallback : parsed;
172
+ } catch (parseErr) {
173
+ // Emit a diagnostic breadcrumb instead of silently returning fallback.
174
+ // The hook must still continue (soft-fail), but the corruption is
175
+ // now visible in \`state/hook-errors.jsonl\` and to \`cclaw doctor\`.
176
+ if (options.root) {
177
+ await recordHookError(
178
+ options.root,
179
+ options.stage || "read-json",
180
+ "corrupt-json file=" + filePath + " error=" + (parseErr instanceof Error ? parseErr.message : String(parseErr))
181
+ );
182
+ }
183
+ return fallback;
184
+ }
60
185
  } catch {
61
186
  return fallback;
62
187
  }
63
188
  }
64
189
 
65
190
  async function writeJsonFile(filePath, value) {
66
- await fs.mkdir(path.dirname(filePath), { recursive: true });
67
191
  const next = JSON.stringify(value, null, 2) + "\\n";
68
- await fs.writeFile(filePath, next, "utf8");
192
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
193
+ await writeFileAtomic(filePath, next);
194
+ });
69
195
  }
70
196
 
71
197
  async function fileExists(filePath) {
@@ -86,8 +212,17 @@ async function readTextFile(filePath, fallback = "") {
86
212
  }
87
213
 
88
214
  async function appendJsonLine(filePath, value) {
89
- await fs.mkdir(path.dirname(filePath), { recursive: true });
90
- await fs.appendFile(filePath, JSON.stringify(value) + "\\n", "utf8");
215
+ const payload = JSON.stringify(value) + "\\n";
216
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
217
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
218
+ await fs.appendFile(filePath, payload, "utf8");
219
+ });
220
+ }
221
+
222
+ async function writeTextFileAtomic(filePath, content) {
223
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
224
+ await writeFileAtomic(filePath, content);
225
+ });
91
226
  }
92
227
 
93
228
  async function readStdin() {
@@ -532,7 +667,10 @@ function extractCodePathsFromText(value) {
532
667
 
533
668
  async function readFlowState(root) {
534
669
  const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
535
- const parsed = await readJsonFile(statePath, {});
670
+ // Loud-on-corrupt: if flow-state.json exists but fails JSON.parse, log
671
+ // a breadcrumb into state/hook-errors.jsonl before falling back to an
672
+ // empty object. Silent fallbacks used to mask stale CLI+hook drift.
673
+ const parsed = await readJsonFile(statePath, {}, { root, stage: "read-flow-state" });
536
674
  const obj = toObject(parsed) || {};
537
675
  const completed = Array.isArray(obj.completedStages) ? obj.completedStages : [];
538
676
  return {
@@ -602,11 +740,9 @@ async function buildKnowledgeDigest(root, currentStage) {
602
740
  });
603
741
  const body =
604
742
  relevant.length > 0 ? relevant.join("\\n") : "(no matching entries for current stage)";
605
- await fs.mkdir(path.dirname(digestFile), { recursive: true });
606
- await fs.writeFile(
743
+ await writeTextFileAtomic(
607
744
  digestFile,
608
- "# Knowledge digest (auto-generated)\\n\\n" + body + "\\n",
609
- "utf8"
745
+ "# Knowledge digest (auto-generated)\\n\\n" + body + "\\n"
610
746
  );
611
747
  return {
612
748
  digestLines: relevant,
@@ -1069,8 +1205,7 @@ async function handlePreCompact(runtime) {
1069
1205
  digest.push("", "## Knowledge tail", knowledgeTail);
1070
1206
  }
1071
1207
  const digestFile = path.join(stateDir, "session-digest.md");
1072
- await fs.mkdir(path.dirname(digestFile), { recursive: true });
1073
- await fs.writeFile(digestFile, digest.join("\\n") + "\\n", "utf8");
1208
+ await writeTextFileAtomic(digestFile, digest.join("\\n") + "\\n");
1074
1209
  return 0;
1075
1210
  }
1076
1211
 
@@ -1,3 +1,19 @@
1
+ import { type HookHandlerId, type HookManifestHarness } from "./hook-manifest.js";
1
2
  export declare function claudeHooksJsonWithObservation(): string;
2
3
  export declare function cursorHooksJsonWithObservation(): string;
3
4
  export declare function codexHooksJsonWithObservation(): string;
5
+ /**
6
+ * Public accessor so diagnostic CLIs and tests can inspect the
7
+ * manifest without importing the private generator helpers.
8
+ */
9
+ export declare function hookManifestSnapshot(): Array<{
10
+ harness: HookManifestHarness;
11
+ events: Array<{
12
+ event: string;
13
+ entries: Array<{
14
+ handler: HookHandlerId;
15
+ matcher?: string;
16
+ timeout?: number;
17
+ }>;
18
+ }>;
19
+ }>;