@quolu/lattice 0.52.0 → 0.52.2

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.
Files changed (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. package/src/witness-scaffold.mjs +180 -180
package/src/hooks-cli.mjs CHANGED
@@ -1,1053 +1,1057 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash, randomBytes } from 'node:crypto';
3
- import { constants as fsConstants } from 'node:fs';
4
- import {
5
- access, lstat, link, mkdir, open, readFile, readdir, realpath, rename, stat, unlink,
6
- } from 'node:fs/promises';
7
- import os from 'node:os';
8
- import path from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- const INFO = 'INFO: このrepoにはLattice sensor index(.lattice/sensor/)があります。コード構造の調査はsensor入口(MCP: lattice_sensor_explore 等/CLI: lattice sensor)を優先できます。';
12
- const HOSTS = new Set(['claude', 'codex']);
13
- const MAX_STDIN_BYTES = 64 * 1024;
14
- const SHOWN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
15
- const CLAIM_MAX_AGE_MS = 60 * 60 * 1000;
16
- const RECEIPT_LOCK_MAX_AGE_MS = 30 * 1000;
17
- const binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../bin/lattice.mjs');
18
- const hash = (value) => createHash('sha256').update(value).digest('hex');
19
- const unique = () => `${Date.now()}-${process.pid}-${randomBytes(8).toString('hex')}`;
20
- const ownUid = () => typeof process.getuid === 'function' ? process.getuid() : null;
21
-
22
- function writeJson(stdout, value) {
23
- stdout.write(`${JSON.stringify(value)}\n`);
24
- }
25
-
26
- function failure(stdout, code, message, exit = 1, detail) {
27
- const value = { schema: 'lattice.hooks_error.v1', code, message };
28
- if (detail !== undefined) value.detail = detail;
29
- writeJson(stdout, value);
30
- return exit;
31
- }
32
-
33
- function configPath(home, host) {
34
- return path.join(home, host === 'claude' ? '.claude/settings.json' : '.codex/hooks.json');
35
- }
36
-
37
- function stateBase(env) {
38
- if (path.isAbsolute(env.XDG_STATE_HOME ?? '')) return path.resolve(env.XDG_STATE_HOME);
39
- return path.join(env.HOME ?? os.homedir(), '.local', 'state');
40
- }
41
-
42
- function words(command) {
43
- const out = [];
44
- let word = '';
45
- let quote = null;
46
- let started = false;
47
- for (let index = 0; index < command.length; index += 1) {
48
- const char = command[index];
49
- if (char === '\\' && quote !== "'") {
50
- const next = command[index + 1];
51
- if (next === undefined) return null;
52
- const escapesNext = quote !== '"' || ['$', '`', '"', '\\', '\n'].includes(next);
53
- if (!escapesNext) {
54
- word += '\\';
55
- started = true;
56
- continue;
57
- }
58
- index += 1;
59
- if (next !== '\n') {
60
- word += next;
61
- started = true;
62
- }
63
- continue;
64
- }
65
- if ((char === "'" || char === '"') && (!quote || quote === char)) {
66
- quote = quote ? null : char;
67
- started = true;
68
- continue;
69
- }
70
- if (/\s/u.test(char) && !quote) {
71
- if (started) out.push(word);
72
- word = '';
73
- started = false;
74
- } else {
75
- word += char;
76
- started = true;
77
- }
78
- }
79
- if (quote) return null;
80
- if (started) out.push(word);
81
- return out;
82
- }
83
-
84
- function shell(argv) {
85
- return argv.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ');
86
- }
87
-
88
- function sameArgv(left, right) {
89
- return left.length === right.length && left.every((part, index) => part === right[index]);
90
- }
91
-
92
- function commandIs(command, identities) {
93
- const parsed = words(command);
94
- return parsed !== null && identities.some((identity) => sameArgv(identity, parsed));
95
- }
96
-
97
- function emitCandidate(command, host) {
98
- const parsed = words(command);
99
- if (parsed === null) return false;
100
- return parsed.some((part, index) => part === 'hooks' && parsed[index + 1] === 'emit'
101
- && parsed[index + 2] === '--host' && parsed[index + 3] === host);
102
- }
103
-
104
- async function fsyncDir(directory) {
105
- const handle = await open(directory, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0));
106
- try { await handle.sync(); } finally { await handle.close(); }
107
- }
108
-
109
- function validateDirectory(info, label) {
110
- if (info.isSymbolicLink() || !info.isDirectory()) {
111
- throw Object.assign(new Error(`${label} is not a real directory`), { code: 'STATE_UNSAFE' });
112
- }
113
- const uid = ownUid();
114
- if ((uid !== null && info.uid !== uid) || (info.mode & 0o022) !== 0) {
115
- throw Object.assign(new Error(`${label} has unsafe ownership or mode`), { code: 'STATE_UNSAFE' });
116
- }
117
- }
118
-
119
- /** Resolve the deepest existing ancestor before creating one component at a time. */
120
- async function secureStateDirectory(env, components = [], { create = true } = {}) {
121
- const requestedBase = stateBase(env);
122
- let ancestor = requestedBase;
123
- const missing = [];
124
- while (true) {
125
- try {
126
- const info = await lstat(ancestor);
127
- validateDirectory(info, ancestor);
128
- break;
129
- } catch (error) {
130
- if (error?.code !== 'ENOENT') throw error;
131
- if (ancestor === path.parse(ancestor).root) throw error;
132
- missing.unshift(path.basename(ancestor));
133
- ancestor = path.dirname(ancestor);
134
- }
135
- }
136
- if (!create && missing.length > 0) return null;
137
- const pinnedAncestor = await realpath(ancestor);
138
- validateDirectory(await lstat(pinnedAncestor), pinnedAncestor);
139
- let cursor = pinnedAncestor;
140
- for (const component of missing) {
141
- cursor = path.join(cursor, component);
142
- try {
143
- await mkdir(cursor, { mode: 0o700 });
144
- await fsyncDir(path.dirname(cursor));
145
- } catch (error) {
146
- if (error?.code !== 'EEXIST') throw error;
147
- }
148
- validateDirectory(await lstat(cursor), cursor);
149
- }
150
- for (const component of components) {
151
- cursor = path.join(cursor, component);
152
- try {
153
- validateDirectory(await lstat(cursor), cursor);
154
- continue;
155
- } catch (error) {
156
- if (error?.code !== 'ENOENT') throw error;
157
- if (!create) return null;
158
- }
159
- try {
160
- await mkdir(cursor, { mode: 0o700 });
161
- await fsyncDir(path.dirname(cursor));
162
- } catch (error) {
163
- if (error?.code !== 'EEXIST') throw error;
164
- }
165
- validateDirectory(await lstat(cursor), cursor);
166
- }
167
- return cursor;
168
- }
169
-
170
- function receiptFile(directory, host) {
171
- return path.join(directory, 'installs', `${host}.json`);
172
- }
173
-
174
- function validateRegularOwnerMode(info, mode, code) {
175
- const uid = ownUid();
176
- if (!info.isFile() || (uid !== null && info.uid !== uid) || (info.mode & 0o777) !== mode) {
177
- throw Object.assign(new Error('unsafe owned file'), { code });
178
- }
179
- }
180
-
181
- async function readOwnedFile(target, { absent = null, code = 'UNSAFE_FILE' } = {}) {
182
- let handle;
183
- try {
184
- handle = await open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
185
- } catch (error) {
186
- if (error?.code === 'ENOENT') return absent;
187
- throw Object.assign(error, { code });
188
- }
189
- try {
190
- validateRegularOwnerMode(await handle.stat(), 0o600, code);
191
- return await handle.readFile();
192
- } finally {
193
- await handle.close();
194
- }
195
- }
196
-
197
- function parseReceipt(bytes) {
198
- if (bytes === null) return { schema: 'lattice.hooks_install_receipt.v1', entries: [] };
199
- let value;
200
- try { value = JSON.parse(bytes); } catch {
201
- throw Object.assign(new Error('receipt is not JSON'), { code: 'INSTALL_RECEIPT_UNSAFE' });
202
- }
203
- if (value?.schema !== 'lattice.hooks_install_receipt.v1' || !Array.isArray(value.entries)
204
- || value.entries.some((entry) => !Array.isArray(entry?.argv)
205
- || !entry.argv.every((part) => typeof part === 'string')
206
- || !['pending', 'committed'].includes(entry.status))) {
207
- throw Object.assign(new Error('receipt shape is invalid'), { code: 'INSTALL_RECEIPT_UNSAFE' });
208
- }
209
- return value;
210
- }
211
-
212
- async function receiptLocation(env, host, create) {
213
- const hooksDirectory = await secureStateDirectory(env, ['lattice', 'hooks'], { create });
214
- if (hooksDirectory === null) return null;
215
- const installs = path.join(hooksDirectory, 'installs');
216
- if (create) {
217
- try { await mkdir(installs, { mode: 0o700 }); await fsyncDir(hooksDirectory); } catch (error) {
218
- if (error?.code !== 'EEXIST') throw error;
219
- }
220
- validateDirectory(await lstat(installs), installs);
221
- } else {
222
- try { validateDirectory(await lstat(installs), installs); } catch (error) {
223
- if (error?.code === 'ENOENT') return null;
224
- throw error;
225
- }
226
- }
227
- return receiptFile(hooksDirectory, host);
228
- }
229
-
230
- async function readReceipt(env, host) {
231
- const target = await receiptLocation(env, host, false);
232
- if (target === null) return { target: null, value: parseReceipt(null) };
233
- return {
234
- target,
235
- value: parseReceipt(await readOwnedFile(target, {
236
- absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
237
- })),
238
- };
239
- }
240
-
241
- async function writeReceiptUnlocked(target, value) {
242
- const directory = path.dirname(target);
243
- const existing = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
244
- if (existing !== null) parseReceipt(existing);
245
- const tmp = `${target}.tmp-lattice-hooks-${unique()}`;
246
- let handle;
247
- try {
248
- handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
249
- | fsConstants.O_NOFOLLOW, 0o600);
250
- await handle.chmod(0o600);
251
- await handle.writeFile(`${JSON.stringify(value)}\n`);
252
- await handle.sync();
253
- await handle?.close();
254
- handle = null;
255
- const beforeRename = await readOwnedFile(target, {
256
- absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
257
- });
258
- if ((existing === null) !== (beforeRename === null)
259
- || (existing !== null && !existing.equals(beforeRename))) {
260
- throw Object.assign(new Error('receipt changed concurrently'), { code: 'INSTALL_RECEIPT_BUSY' });
261
- }
262
- await rename(tmp, target);
263
- await fsyncDir(directory);
264
- parseReceipt(await readOwnedFile(target, { code: 'INSTALL_RECEIPT_UNSAFE' }));
265
- } finally {
266
- await handle?.close().catch(() => {});
267
- await removeArtifact(tmp).catch(() => {});
268
- }
269
- }
270
-
271
- async function withReceiptLock(env, host, operation) {
272
- const target = await receiptLocation(env, host, true);
273
- const lockPath = `${target}.lock`;
274
- let lock;
275
- let acquired = false;
276
- for (let attempt = 0; attempt < 60; attempt += 1) {
277
- let created = false;
278
- try {
279
- lock = await open(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
280
- | fsConstants.O_NOFOLLOW, 0o600);
281
- created = true;
282
- await lock.chmod(0o600);
283
- await lock.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
284
- await lock.sync();
285
- await lock.close();
286
- lock = null;
287
- await fsyncDir(path.dirname(lockPath));
288
- acquired = true;
289
- break;
290
- } catch (error) {
291
- await lock?.close().catch(() => {});
292
- lock = null;
293
- if (created) await removeArtifact(lockPath).catch(() => {});
294
- if (error?.code !== 'EEXIST') throw error;
295
- try {
296
- if (Date.now() - (await lstat(lockPath)).mtimeMs > RECEIPT_LOCK_MAX_AGE_MS) {
297
- await unlink(lockPath);
298
- await fsyncDir(path.dirname(lockPath));
299
- continue;
300
- }
301
- } catch (statError) {
302
- if (statError?.code === 'ENOENT') continue;
303
- throw statError;
304
- }
305
- await new Promise((resolve) => setTimeout(resolve, 10));
306
- }
307
- }
308
- if (!acquired) {
309
- throw Object.assign(new Error('receipt lock unavailable'), { code: 'INSTALL_RECEIPT_BUSY' });
310
- }
311
- try {
312
- const bytes = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
313
- return await operation(parseReceipt(bytes), target);
314
- } finally {
315
- await unlink(lockPath).catch(() => {});
316
- await fsyncDir(path.dirname(lockPath)).catch(() => {});
317
- }
318
- }
319
-
320
- function allHandlers(value) {
321
- const list = value?.hooks?.UserPromptSubmit;
322
- if (!Array.isArray(list)) return [];
323
- return list.flatMap((wrapper) => Array.isArray(wrapper?.hooks) ? wrapper.hooks : []);
324
- }
325
-
326
- function configContains(value, argv) {
327
- return allHandlers(value).some((item) => item?.type === 'command'
328
- && typeof item.command === 'string' && commandIs(item.command, [argv]));
329
- }
330
-
331
- async function recoverReceipt(env, host, configTarget, testHooks) {
332
- const current = await readReceipt(env, host);
333
- if (current.target === null || !current.value.entries.some((entry) => entry.status === 'pending')) {
334
- return current.value;
335
- }
336
- return withReceiptLock(env, host, async (receipt, target) => {
337
- await testHooks.afterReceiptLock?.({ configTarget });
338
- const config = await readConfig(configTarget);
339
- const entries = receipt.entries.flatMap((entry) => {
340
- if (entry.status !== 'pending') return [entry];
341
- return configContains(config.value, entry.argv) ? [{ ...entry, status: 'committed' }] : [];
342
- });
343
- const recovered = { ...receipt, entries };
344
- await writeReceiptUnlocked(target, recovered);
345
- return recovered;
346
- });
347
- }
348
-
349
- async function appendPending(env, host, argv) {
350
- const operationId = unique();
351
- await withReceiptLock(env, host, async (receipt, target) => {
352
- const entries = [...receipt.entries];
353
- entries.push({ argv, status: 'pending', operation_id: operationId, recorded_at: new Date().toISOString() });
354
- await writeReceiptUnlocked(target, { ...receipt, entries });
355
- });
356
- return operationId;
357
- }
358
-
359
- async function commitPending(env, host, operationId, argv) {
360
- await withReceiptLock(env, host, async (receipt, target) => {
361
- let found = false;
362
- const entries = receipt.entries.map((entry) => {
363
- if (entry.operation_id !== operationId) return entry;
364
- found = true;
365
- return { ...entry, status: 'committed' };
366
- });
367
- if (!found) entries.push({
368
- argv, status: 'committed', operation_id: operationId, recorded_at: new Date().toISOString(),
369
- });
370
- await writeReceiptUnlocked(target, { ...receipt, entries });
371
- });
372
- }
373
-
374
- async function readConfig(target) {
375
- let info;
376
- try { info = await lstat(target); } catch (error) {
377
- if (error?.code === 'ENOENT') {
378
- return { existed: false, mode: 0o600, bytes: Buffer.alloc(0), value: {} };
379
- }
380
- throw error;
381
- }
382
- if (info.isSymbolicLink()) throw Object.assign(new Error('config is symlink'), { code: 'SYMLINK' });
383
- if (!info.isFile()) throw Object.assign(new Error('config is not regular'), { code: 'CONFIG_INVALID' });
384
- const bytes = await readFile(target);
385
- let value;
386
- try { value = JSON.parse(bytes); } catch {
387
- throw Object.assign(new Error('config is not JSON'), { code: 'CONFIG_INVALID' });
388
- }
389
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
390
- throw Object.assign(new Error('config root is not an object'), { code: 'CONFIG_INVALID' });
391
- }
392
- if (value.hooks !== undefined && (value.hooks === null || typeof value.hooks !== 'object'
393
- || Array.isArray(value.hooks))) {
394
- throw Object.assign(new Error('hooks is not an object'), { code: 'CONFIG_INVALID' });
395
- }
396
- if (value.hooks?.UserPromptSubmit !== undefined && !Array.isArray(value.hooks.UserPromptSubmit)) {
397
- throw Object.assign(new Error('UserPromptSubmit is not an array'), { code: 'CONFIG_INVALID' });
398
- }
399
- return { existed: true, mode: info.mode & 0o777, bytes, value };
400
- }
401
-
402
- function hooksList(value) {
403
- if (value.hooks === undefined) value.hooks = {};
404
- if (value.hooks.UserPromptSubmit === undefined) value.hooks.UserPromptSubmit = [];
405
- return value.hooks.UserPromptSubmit;
406
- }
407
-
408
- function stripIdentity(value, identities) {
409
- let removed = 0;
410
- const list = hooksList(value);
411
- value.hooks.UserPromptSubmit = list.flatMap((wrapper) => {
412
- if (!wrapper || !Array.isArray(wrapper.hooks)) return [wrapper];
413
- const handlers = wrapper.hooks.filter((item) => {
414
- const owned = item?.type === 'command' && typeof item.command === 'string'
415
- && commandIs(item.command, identities);
416
- if (owned) removed += 1;
417
- return !owned;
418
- });
419
- return handlers.length > 0 ? [{ ...wrapper, hooks: handlers }] : [];
420
- });
421
- return removed;
422
- }
423
-
424
- function hostHandler(host, command) {
425
- return host === 'claude'
426
- ? { type: 'command', command, timeout: 5 }
427
- : { type: 'command', command, timeout: 5, async: false, statusMessage: null };
428
- }
429
-
430
- export async function resolveStableNodePath(execPath, {
431
- platform = process.platform,
432
- accessImpl = access,
433
- realpathImpl = realpath,
434
- } = {}) {
435
- await accessImpl(execPath, fsConstants.X_OK);
436
- if (platform !== 'darwin') return execPath;
437
- const match = execPath.match(/^\/(opt\/homebrew|usr\/local)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/u);
438
- if (match === null) return execPath;
439
- const prefix = `/${match[1]}`;
440
- const candidates = [path.join(prefix, 'bin/node'), path.join(prefix, 'opt', match[2], 'bin/node')];
441
- const resolvedExec = await realpathImpl(execPath);
442
- for (const candidate of candidates) {
443
- try {
444
- await accessImpl(candidate, fsConstants.X_OK);
445
- if (await realpathImpl(candidate) === resolvedExec) return candidate;
446
- } catch {
447
- // A candidate is usable only when it exists and resolves to this running Node binary.
448
- }
449
- }
450
- return execPath;
451
- }
452
-
453
- async function resolveCanonical(host, source, platform) {
454
- const sourcePaths = [source.execPath, source.binPath];
455
- if (sourcePaths.some((entry) => typeof entry !== 'string' || !path.isAbsolute(entry)
456
- || /[\0\r\n]/u.test(entry))) {
457
- throw Object.assign(new Error('install source is not absolute'), { code: 'INSTALL_SOURCE_UNRESOLVED' });
458
- }
459
- try {
460
- const executable = await resolveStableNodePath(source.execPath, { platform });
461
- const script = await realpath(source.binPath);
462
- if (/[\0\r\n]/u.test(script)) throw new Error('resolved install source has unsafe characters');
463
- await access(script, fsConstants.R_OK | fsConstants.X_OK);
464
- return [executable, script, 'hooks', 'emit', '--host', host];
465
- } catch (error) {
466
- throw Object.assign(error, { code: 'INSTALL_SOURCE_UNRESOLVED' });
467
- }
468
- }
469
-
470
- async function createBackup(target, prestate) {
471
- if (!prestate.existed) return null;
472
- const ref = `${target}.bak-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
473
- let handle;
474
- try {
475
- handle = await open(ref, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
476
- | fsConstants.O_NOFOLLOW, 0o600);
477
- await handle.chmod(0o600);
478
- await handle.writeFile(prestate.bytes);
479
- await handle.sync();
480
- await handle.close();
481
- handle = null;
482
- await fsyncDir(path.dirname(target));
483
- return ref;
484
- } catch (error) {
485
- await handle?.close().catch(() => {});
486
- await removeArtifact(ref).catch(() => {});
487
- throw error;
488
- }
489
- }
490
-
491
- async function removeArtifact(target) {
492
- if (target === null) return;
493
- try { await unlink(target); await fsyncDir(path.dirname(target)); } catch (error) {
494
- if (error?.code !== 'ENOENT') throw error;
495
- }
496
- }
497
-
498
- async function pruneGenerations(target) {
499
- const directory = path.dirname(target);
500
- const basename = path.basename(target);
501
- for (const marker of ['bak-lattice-hooks-', 'pre-lattice-hooks-']) {
502
- const prefix = `${basename}.${marker}`;
503
- const entries = [];
504
- for (const name of await readdir(directory)) {
505
- if (!name.startsWith(prefix)) continue;
506
- const full = path.join(directory, name);
507
- try { entries.push({ full, mtimeMs: (await lstat(full)).mtimeMs }); } catch (error) {
508
- if (error?.code !== 'ENOENT') throw error;
509
- }
510
- }
511
- entries.sort((left, right) => right.mtimeMs - left.mtimeMs || right.full.localeCompare(left.full));
512
- for (const entry of entries.slice(5)) await removeArtifact(entry.full);
513
- }
514
- }
515
-
516
- async function writeTmp(target, bytes, mode, tag = 'tmp') {
517
- const tmp = `${target}.${tag}-lattice-hooks-${unique()}`;
518
- let handle;
519
- try {
520
- handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
521
- | fsConstants.O_NOFOLLOW, mode);
522
- await handle.chmod(mode);
523
- await handle.writeFile(bytes);
524
- await handle.sync();
525
- await handle.close();
526
- handle = null;
527
- return tmp;
528
- } catch (error) {
529
- await handle?.close().catch(() => {});
530
- await removeArtifact(tmp).catch(() => {});
531
- throw error;
532
- }
533
- }
534
-
535
- async function restoreExisting(target, displaced, mode, testHooks) {
536
- await testHooks.beforeRestore?.({ target, displaced });
537
- const bytes = await readFile(displaced);
538
- const tmp = await writeTmp(target, bytes, mode, 'restore');
539
- try {
540
- await rename(tmp, target);
541
- await fsyncDir(path.dirname(target));
542
- if (!(await readFile(target)).equals(bytes)) throw new Error('restore read-back mismatch');
543
- } finally {
544
- await unlink(tmp).catch(() => {});
545
- }
546
- }
547
-
548
- async function rollbackAbsent(target, tmp) {
549
- const targetInfo = await lstat(target);
550
- const tmpInfo = await lstat(tmp);
551
- if (targetInfo.dev !== tmpInfo.dev || targetInfo.ino !== tmpInfo.ino) {
552
- throw new Error('created target was replaced before rollback');
553
- }
554
- await unlink(target);
555
- await fsyncDir(path.dirname(target));
556
- try { await lstat(target); throw new Error('absent rollback read-back failed'); } catch (error) {
557
- if (error?.code !== 'ENOENT') throw error;
558
- }
559
- }
560
-
561
- async function commitConfig(target, prestate, value, testHooks) {
562
- const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
563
- JSON.parse(serialized);
564
- const tmp = await writeTmp(target, serialized, prestate.existed ? prestate.mode : 0o600);
565
- let displaced = null;
566
- let committed = false;
567
- let complete = false;
568
- try {
569
- if (prestate.existed) {
570
- await testHooks.beforePreimageVerify?.({ target, prestate });
571
- const current = await readConfig(target);
572
- if (!current.existed || !current.bytes.equals(prestate.bytes)) {
573
- throw Object.assign(new Error('preimage changed'), { code: 'PREIMAGE_CHANGED' });
574
- }
575
- displaced = `${target}.pre-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
576
- await link(target, displaced);
577
- await fsyncDir(path.dirname(target));
578
- await testHooks.afterDisplacedLink?.({ target, displaced });
579
- const before = await lstat(target);
580
- const saved = await lstat(displaced);
581
- if (!before.isFile() || before.dev !== saved.dev || before.ino !== saved.ino) {
582
- throw Object.assign(new Error('target inode changed'), { code: 'PREIMAGE_CHANGED' });
583
- }
584
- await rename(tmp, target);
585
- committed = true;
586
- await fsyncDir(path.dirname(target));
587
- } else {
588
- try { await link(tmp, target); } catch (error) {
589
- if (error?.code === 'EEXIST') {
590
- throw Object.assign(error, { code: 'PREIMAGE_CHANGED' });
591
- }
592
- throw error;
593
- }
594
- committed = true;
595
- await fsyncDir(path.dirname(target));
596
- }
597
- await testHooks.beforeConfigReadBack?.({ target, serialized, displaced });
598
- if (!(await readFile(target)).equals(serialized)) throw new Error('config read-back mismatch');
599
- complete = true;
600
- return displaced;
601
- } catch (error) {
602
- if (committed) {
603
- try {
604
- if (prestate.existed) await restoreExisting(target, displaced, prestate.mode, testHooks);
605
- else await rollbackAbsent(target, tmp);
606
- } catch (restoreError) {
607
- throw Object.assign(new Error(`restore failed: ${restoreError.message}`), {
608
- code: 'RESTORE_FAILED', cause: error, commitOccurred: true, displacedPath: displaced,
609
- });
610
- }
611
- Object.assign(error, { commitOccurred: true, displacedPath: displaced });
612
- }
613
- throw error;
614
- } finally {
615
- await removeArtifact(tmp).catch(() => {});
616
- if (!complete && !committed) await removeArtifact(displaced).catch(() => {});
617
- }
618
- }
619
-
620
- function committedIdentities(receipt) {
621
- return receipt.entries.filter((entry) => entry.status === 'committed').map((entry) => entry.argv);
622
- }
623
-
624
- function exactHandler(item, expected) {
625
- return JSON.stringify(item) === JSON.stringify(expected);
626
- }
627
-
628
- async function hostDirectory(home, host) {
629
- const directory = path.dirname(configPath(home, host));
630
- try {
631
- const info = await lstat(directory);
632
- if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('not a directory');
633
- return directory;
634
- } catch {
635
- return null;
636
- }
637
- }
638
-
639
- async function mutate(host, env, stdout, uninstall, source, platform, testHooks) {
640
- const home = env.HOME ?? os.homedir();
641
- if (await hostDirectory(home, host) === null) {
642
- return failure(stdout, 'HOST_NOT_PRESENT', 'host home directory is not present');
643
- }
644
- let current;
645
- try { current = await resolveCanonical(host, source, platform); } catch {
646
- return failure(stdout, 'INSTALL_SOURCE_UNRESOLVED', 'install source cannot be resolved');
647
- }
648
- const target = configPath(home, host);
649
- for (let attempt = 0; attempt < 2; attempt += 1) {
650
- let prestate;
651
- try { prestate = await readConfig(target); } catch (error) {
652
- return failure(stdout, error?.code === 'SYMLINK' ? 'CONFIG_SYMLINK_UNSUPPORTED'
653
- : 'CONFIG_UNREADABLE', 'configuration cannot be read');
654
- }
655
- let receipt;
656
- try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
657
- return failure(stdout, 'INSTALL_RECEIPT_UNSAFE', 'install receipt cannot be safely read');
658
- }
659
- const identities = [current, ...committedIdentities(receipt)];
660
- const next = structuredClone(prestate.value);
661
- const removed = stripIdentity(next, identities);
662
- if (!uninstall) hooksList(next).push({ hooks: [hostHandler(host, shell(current))] });
663
-
664
- const currentHandlers = allHandlers(prestate.value).filter((item) => item?.type === 'command'
665
- && typeof item.command === 'string' && commandIs(item.command, identities));
666
- const alreadyWired = !uninstall && currentHandlers.length === 1
667
- && commandIs(currentHandlers[0].command, [current])
668
- && exactHandler(currentHandlers[0], hostHandler(host, shell(current)));
669
- if ((uninstall && removed === 0) || alreadyWired) {
670
- writeJson(stdout, uninstall
671
- ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: 0 }
672
- : { schema: 'lattice.hooks_install_result.v1', host, state: 'already_wired' });
673
- return 0;
674
- }
675
-
676
- let pendingId = null;
677
- let backup = null;
678
- let configCommitted = false;
679
- try {
680
- if (!uninstall) pendingId = await appendPending(env, host, current);
681
- backup = await createBackup(target, prestate);
682
- await commitConfig(target, prestate, next, testHooks);
683
- configCommitted = true;
684
- if (!uninstall) await commitPending(env, host, pendingId, current);
685
- let warning;
686
- try {
687
- await testHooks.beforePrune?.({ target });
688
- await pruneGenerations(target);
689
- } catch (error) {
690
- warning = { code: 'GENERATION_PRUNE_FAILED', message: error?.message ?? 'generation prune failed' };
691
- }
692
- const result = uninstall
693
- ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: removed }
694
- : { schema: 'lattice.hooks_install_result.v1', host, state: 'wired' };
695
- if (warning !== undefined) result.warning = warning;
696
- writeJson(stdout, result);
697
- return 0;
698
- } catch (error) {
699
- if (!configCommitted && !error?.commitOccurred) await removeArtifact(backup).catch(() => {});
700
- if (!configCommitted && error?.code === 'PREIMAGE_CHANGED' && attempt === 0) continue;
701
- const code = error?.code === 'RESTORE_FAILED' ? 'RESTORE_FAILED'
702
- : ['INSTALL_RECEIPT_UNSAFE', 'INSTALL_RECEIPT_BUSY'].includes(error?.code)
703
- ? 'INSTALL_RECEIPT_UNSAFE'
704
- : 'CONFIG_WRITE_FAILED';
705
- const detail = code === 'RESTORE_FAILED' ? {
706
- backup_path: backup,
707
- displaced_path: error.displacedPath,
708
- } : undefined;
709
- return failure(stdout, code, 'configuration cannot be safely written', 1, detail);
710
- }
711
- }
712
- return failure(stdout, 'CONFIG_WRITE_FAILED', 'configuration changed concurrently');
713
- }
714
-
715
- function statusResult(host, target, canonicalCommand, state, matches, executableOk,
716
- foreignCandidateCount) {
717
- return {
718
- schema: 'lattice.hooks_status_result.v1',
719
- host,
720
- config_path: target,
721
- state,
722
- canonical_command: canonicalCommand,
723
- matched_handler_count: matches,
724
- foreign_candidate_count: foreignCandidateCount,
725
- executable_ok: executableOk,
726
- next_action: state === 'wired' ? null : `lattice hooks install --host ${host}`,
727
- };
728
- }
729
-
730
- async function status(host, env, stdout, source, platform, testHooks) {
731
- const home = env.HOME ?? os.homedir();
732
- const target = configPath(home, host);
733
- let argv;
734
- try { argv = await resolveCanonical(host, source, platform); } catch {
735
- writeJson(stdout, statusResult(host, target, null, 'unreadable', 0, false, 0));
736
- return 1;
737
- }
738
- if (platform === 'win32' || await hostDirectory(home, host) === null) {
739
- writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
740
- return 1;
741
- }
742
- let config;
743
- try { config = await readConfig(target); } catch {
744
- writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
745
- return 1;
746
- }
747
- let receipt;
748
- try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
749
- writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
750
- return 1;
751
- }
752
- try { config = await readConfig(target); } catch {
753
- writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
754
- return 1;
755
- }
756
- const identities = [argv, ...committedIdentities(receipt)];
757
- let matches = 0;
758
- let canonicalMatches = 0;
759
- let foreign = 0;
760
- let canonicalShape = false;
761
- for (const item of allHandlers(config.value)) {
762
- if (item?.type !== 'command' || typeof item.command !== 'string') continue;
763
- if (commandIs(item.command, identities)) {
764
- matches += 1;
765
- if (commandIs(item.command, [argv])) {
766
- canonicalMatches += 1;
767
- canonicalShape = exactHandler(item, hostHandler(host, shell(argv)));
768
- }
769
- } else if (emitCandidate(item.command, host)) foreign += 1;
770
- }
771
- const executableOk = await Promise.all(argv.slice(0, 2).map(async (entry) => {
772
- try { await access(entry, fsConstants.X_OK); return (await stat(entry)).isFile(); } catch { return false; }
773
- })).then((values) => values.every(Boolean));
774
- let state = 'drift';
775
- if (matches === 0 && foreign === 0) state = 'not_wired';
776
- else if (matches === 1 && canonicalMatches === 1 && canonicalShape && executableOk && foreign === 0) {
777
- state = 'wired';
778
- }
779
- writeJson(stdout, statusResult(host, target, shell(argv), state, matches, executableOk, foreign));
780
- return 0;
781
- }
782
-
783
- async function appendError(state, message) {
784
- const target = path.join(state, 'errors.log');
785
- try {
786
- const created = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT
787
- | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
788
- try { await created.chmod(0o600); await created.sync(); } finally { await created.close(); }
789
- await fsyncDir(state);
790
- } catch (error) {
791
- if (error?.code !== 'EEXIST') throw error;
792
- }
793
- const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
794
- | fsConstants.O_NOFOLLOW, 0o600);
795
- try {
796
- validateRegularOwnerMode(await handle.stat(), 0o600, 'ERROR_LOG_UNSAFE');
797
- await handle.writeFile(`${new Date().toISOString()} ${message}\n`);
798
- await handle.sync();
799
- } finally { await handle.close(); }
800
- }
801
-
802
- async function recordOrDiagnose(state, stdout, message, diagnostic) {
803
- try { await appendError(state, message); } catch { stdout.write(`Lattice hooks: ${diagnostic}\n`); }
804
- }
805
-
806
- async function diagnose(state, stdout, message, diagnostic) {
807
- try { await appendError(state, message); } catch {}
808
- stdout.write(`Lattice hooks: ${diagnostic}\n`);
809
- }
810
-
811
- async function readHookInput(stdin) {
812
- const chunks = [];
813
- let length = 0;
814
- let tooLarge = false;
815
- for await (const chunk of stdin) {
816
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
817
- length += bytes.length;
818
- if (length <= MAX_STDIN_BYTES) chunks.push(bytes);
819
- else tooLarge = true;
820
- }
821
- if (tooLarge) throw new Error('hook stdin exceeds 64KiB');
822
- let event;
823
- try { event = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
824
- throw new Error('hook stdin is not strict JSON');
825
- }
826
- if (typeof event?.session_id !== 'string' || event.session_id.length === 0
827
- || typeof event.cwd !== 'string' || !path.isAbsolute(event.cwd)) {
828
- throw new Error('hook stdin requires session_id and absolute cwd');
829
- }
830
- try {
831
- const cwd = await realpath(event.cwd);
832
- if (!(await lstat(cwd)).isDirectory()) throw new Error('cwd is not a directory');
833
- return { ...event, cwd };
834
- } catch {
835
- throw new Error('hook cwd is unavailable');
836
- }
837
- }
838
-
839
- async function gitRoot(cwd, spawnImpl, timeoutMs) {
840
- return new Promise((resolve) => {
841
- let settled = false;
842
- let out = '';
843
- let child;
844
- const finish = (result) => {
845
- if (settled) return;
846
- settled = true;
847
- clearTimeout(timer);
848
- resolve(result);
849
- };
850
- try {
851
- child = spawnImpl('git', ['--no-optional-locks', '-C', cwd, 'rev-parse', '--show-toplevel'], {
852
- shell: false, stdio: ['ignore', 'pipe', 'ignore'],
853
- });
854
- } catch (error) {
855
- resolve({ kind: 'error', error });
856
- return;
857
- }
858
- const timer = setTimeout(() => {
859
- child.kill('SIGKILL');
860
- finish({ kind: 'error', error: new Error('git root lookup timed out') });
861
- }, timeoutMs);
862
- child.once('error', (error) => finish({ kind: 'error', error }));
863
- child.stdout.on('error', (error) => finish({ kind: 'error', error }));
864
- child.stdout.on('data', (data) => {
865
- out += data;
866
- if (Buffer.byteLength(out) > MAX_STDIN_BYTES) {
867
- child.kill('SIGKILL');
868
- finish({ kind: 'error', error: new Error('git output too large') });
869
- }
870
- });
871
- child.once('close', (code, signal) => {
872
- if (code !== 0) {
873
- finish(signal === null ? { kind: 'not_git' }
874
- : { kind: 'error', error: new Error(`git terminated by ${signal}`) });
875
- return;
876
- }
877
- const root = out.trim();
878
- finish(path.isAbsolute(root) ? { kind: 'root', root }
879
- : { kind: 'error', error: new Error('git returned a non-absolute root') });
880
- });
881
- });
882
- }
883
-
884
- const ownNotificationPattern = /^[a-f0-9]{64}\.[a-f0-9]{64}\.(?:shown|claim)$/u;
885
-
886
- async function gcNotifications(state) {
887
- const now = Date.now();
888
- for (const name of await readdir(state)) {
889
- if (!ownNotificationPattern.test(name)) continue;
890
- const target = path.join(state, name);
891
- let info;
892
- try { info = await lstat(target); } catch (error) {
893
- if (error?.code === 'ENOENT') continue;
894
- throw error;
895
- }
896
- const maximum = name.endsWith('.claim') ? CLAIM_MAX_AGE_MS : SHOWN_MAX_AGE_MS;
897
- if (now - info.mtimeMs > maximum) await removeArtifact(target);
898
- }
899
- }
900
-
901
- async function freshShown(shown) {
902
- try { return Date.now() - (await lstat(shown)).mtimeMs <= SHOWN_MAX_AGE_MS; } catch (error) {
903
- if (error?.code === 'ENOENT') return false;
904
- throw error;
905
- }
906
- }
907
-
908
- async function acquireClaim(claim) {
909
- let handle;
910
- let created = false;
911
- try {
912
- handle = await open(claim, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
913
- | fsConstants.O_NOFOLLOW, 0o600);
914
- created = true;
915
- await handle.chmod(0o600);
916
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
917
- await handle.sync();
918
- await handle.close();
919
- handle = null;
920
- await fsyncDir(path.dirname(claim));
921
- return true;
922
- } catch (error) {
923
- await handle?.close().catch(() => {});
924
- if (created) await removeArtifact(claim).catch(() => {});
925
- if (error?.code === 'EEXIST') return false;
926
- throw error;
927
- }
928
- }
929
-
930
- function outputLine(host) {
931
- return host === 'claude' ? `${INFO}\n` : `${JSON.stringify({
932
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: INFO },
933
- })}\n`;
934
- }
935
-
936
- async function writeOutput(stdout, line) {
937
- await new Promise((resolve, reject) => {
938
- try { stdout.write(line, (error) => error ? reject(error) : resolve()); } catch (error) { reject(error); }
939
- });
940
- }
941
-
942
- async function emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks) {
943
- if (env.LATTICE_HOOKS === 'off') return 0;
944
- let state;
945
- try { state = await secureStateDirectory(env, ['lattice', 'hooks']); } catch {
946
- stdout.write('Lattice hooks: state directory unavailable\n');
947
- return 0;
948
- }
949
- let event;
950
- try { event = await readHookInput(stdin); } catch (error) {
951
- await recordOrDiagnose(state, stdout, error.message, 'cannot record invalid stdin');
952
- return 0;
953
- }
954
- const git = await gitRoot(event.cwd, spawnImpl, gitTimeoutMs);
955
- if (git.kind === 'not_git') return 0;
956
- if (git.kind === 'error') {
957
- await diagnose(state, stdout, git.error.message, 'git root lookup failed');
958
- return 0;
959
- }
960
- const sensor = path.join(git.root, '.lattice', 'sensor');
961
- try {
962
- if (!(await lstat(sensor)).isDirectory()) return 0;
963
- } catch (error) {
964
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return 0;
965
- await diagnose(state, stdout, `sensor lookup failed: ${error.message}`, 'sensor index unavailable');
966
- return 0;
967
- }
968
- try { await gcNotifications(state); } catch (error) {
969
- await diagnose(state, stdout, `notification gc failed: ${error.message}`, 'notification state unavailable');
970
- return 0;
971
- }
972
- const key = `${hash(event.session_id)}.${hash(git.root)}`;
973
- const shown = path.join(state, `${key}.shown`);
974
- const claim = path.join(state, `${key}.claim`);
975
- try {
976
- if (await freshShown(shown)) return 0;
977
- } catch (error) {
978
- await diagnose(state, stdout, `shown precheck failed: ${error.message}`, 'notification state unavailable');
979
- return 0;
980
- }
981
- try {
982
- if (!await acquireClaim(claim)) return 0;
983
- } catch (error) {
984
- await diagnose(state, stdout, `claim failed: ${error.message}`, 'notification claim unavailable');
985
- return 0;
986
- }
987
- try {
988
- if (await freshShown(shown)) {
989
- await removeArtifact(claim);
990
- return 0;
991
- }
992
- } catch (error) {
993
- await removeArtifact(claim).catch(() => {});
994
- await diagnose(state, stdout, `shown recheck failed: ${error.message}`, 'notification state unavailable');
995
- return 0;
996
- }
997
- try {
998
- await writeOutput(stdout, outputLine(host));
999
- } catch (error) {
1000
- await removeArtifact(claim).catch(() => {});
1001
- await recordOrDiagnose(state, stdout, `notification output failed: ${error.message}`,
1002
- 'notification output failed');
1003
- return 0;
1004
- }
1005
- try {
1006
- await testHooks.beforeShownRename?.({ claim, shown });
1007
- await rename(claim, shown);
1008
- await fsyncDir(state);
1009
- } catch (error) {
1010
- await recordOrDiagnose(state, stdout, `claim promotion failed: ${error.message}`,
1011
- 'notification record failed');
1012
- try {
1013
- const handle = await open(shown, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
1014
- | fsConstants.O_NOFOLLOW, 0o600);
1015
- await handle.chmod(0o600);
1016
- await handle.sync();
1017
- await handle.close();
1018
- await fsyncDir(state);
1019
- } catch (fallbackError) {
1020
- await recordOrDiagnose(state, stdout, `shown fallback failed: ${fallbackError.message}`,
1021
- 'notification record failed');
1022
- }
1023
- await removeArtifact(claim).catch(() => {});
1024
- }
1025
- return 0;
1026
- }
1027
-
1028
- export async function runHooksCli({
1029
- argv,
1030
- stdout,
1031
- stdin = process.stdin,
1032
- env = process.env,
1033
- platform = process.platform,
1034
- source = { execPath: process.execPath, binPath },
1035
- spawnImpl = spawn,
1036
- gitTimeoutMs = 2000,
1037
- testHooks = {},
1038
- }) {
1039
- if (argv.length !== 3 || !['install', 'status', 'uninstall', 'emit'].includes(argv[0])
1040
- || argv[1] !== '--host' || !HOSTS.has(argv[2])) {
1041
- return failure(stdout, 'USAGE',
1042
- 'usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>', 2);
1043
- }
1044
- const [command, , host] = argv;
1045
- if (platform === 'win32') {
1046
- if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1047
- return failure(stdout, 'HOST_PLATFORM_UNSUPPORTED', 'native Windows hooks are unsupported');
1048
- }
1049
- if (command === 'install') return mutate(host, env, stdout, false, source, platform, testHooks);
1050
- if (command === 'uninstall') return mutate(host, env, stdout, true, source, platform, testHooks);
1051
- if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1052
- return emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks);
1053
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import {
5
+ access, lstat, link, mkdir, open, readFile, readdir, realpath, rename, stat, unlink,
6
+ } from 'node:fs/promises';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const INFO = 'INFO: このrepoにはLattice sensor index(.lattice/sensor/)があります。コード構造の調査はsensor入口(MCP: lattice_sensor_explore 等/CLI: lattice sensor)を優先できます。';
12
+ const HOSTS = new Set(['claude', 'codex']);
13
+ const MAX_STDIN_BYTES = 64 * 1024;
14
+ const SHOWN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
15
+ const CLAIM_MAX_AGE_MS = 60 * 60 * 1000;
16
+ const RECEIPT_LOCK_MAX_AGE_MS = 30 * 1000;
17
+ const binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../bin/lattice.mjs');
18
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
19
+ const unique = () => `${Date.now()}-${process.pid}-${randomBytes(8).toString('hex')}`;
20
+ const ownUid = () => typeof process.getuid === 'function' ? process.getuid() : null;
21
+
22
+ function writeJson(stdout, value) {
23
+ stdout.write(`${JSON.stringify(value)}\n`);
24
+ }
25
+
26
+ function failure(stdout, code, message, exit = 1, detail) {
27
+ const value = { schema: 'lattice.hooks_error.v1', code, message };
28
+ if (detail !== undefined) value.detail = detail;
29
+ writeJson(stdout, value);
30
+ return exit;
31
+ }
32
+
33
+ function configPath(home, host) {
34
+ return path.join(home, host === 'claude' ? '.claude/settings.json' : '.codex/hooks.json');
35
+ }
36
+
37
+ function stateBase(env) {
38
+ if (path.isAbsolute(env.XDG_STATE_HOME ?? '')) return path.resolve(env.XDG_STATE_HOME);
39
+ return path.join(env.HOME ?? os.homedir(), '.local', 'state');
40
+ }
41
+
42
+ function words(command) {
43
+ const out = [];
44
+ let word = '';
45
+ let quote = null;
46
+ let started = false;
47
+ for (let index = 0; index < command.length; index += 1) {
48
+ const char = command[index];
49
+ if (char === '\\' && quote !== "'") {
50
+ const next = command[index + 1];
51
+ if (next === undefined) return null;
52
+ const escapesNext = quote !== '"' || ['$', '`', '"', '\\', '\n'].includes(next);
53
+ if (!escapesNext) {
54
+ word += '\\';
55
+ started = true;
56
+ continue;
57
+ }
58
+ index += 1;
59
+ if (next !== '\n') {
60
+ word += next;
61
+ started = true;
62
+ }
63
+ continue;
64
+ }
65
+ if ((char === "'" || char === '"') && (!quote || quote === char)) {
66
+ quote = quote ? null : char;
67
+ started = true;
68
+ continue;
69
+ }
70
+ if (/\s/u.test(char) && !quote) {
71
+ if (started) out.push(word);
72
+ word = '';
73
+ started = false;
74
+ } else {
75
+ word += char;
76
+ started = true;
77
+ }
78
+ }
79
+ if (quote) return null;
80
+ if (started) out.push(word);
81
+ return out;
82
+ }
83
+
84
+ function shell(argv) {
85
+ return argv.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ');
86
+ }
87
+
88
+ function sameArgv(left, right) {
89
+ return left.length === right.length && left.every((part, index) => part === right[index]);
90
+ }
91
+
92
+ function commandIs(command, identities) {
93
+ const parsed = words(command);
94
+ return parsed !== null && identities.some((identity) => sameArgv(identity, parsed));
95
+ }
96
+
97
+ function emitCandidate(command, host) {
98
+ const parsed = words(command);
99
+ if (parsed === null) return false;
100
+ return parsed.some((part, index) => part === 'hooks' && parsed[index + 1] === 'emit'
101
+ && parsed[index + 2] === '--host' && parsed[index + 3] === host);
102
+ }
103
+
104
+ async function fsyncDir(directory) {
105
+ const handle = await open(directory, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0));
106
+ // Windowsはdirectory handleのfsyncを許さず常にEPERM/EINVALを返す(Node仕様)。
107
+ // win32のこの2値だけ許容し、他OS・他エラーは従来どおり失敗させる。
108
+ try { await handle.sync(); } catch (error) {
109
+ if (process.platform !== 'win32' || !['EPERM', 'EINVAL'].includes(error?.code)) throw error;
110
+ } finally { await handle.close(); }
111
+ }
112
+
113
+ function validateDirectory(info, label) {
114
+ if (info.isSymbolicLink() || !info.isDirectory()) {
115
+ throw Object.assign(new Error(`${label} is not a real directory`), { code: 'STATE_UNSAFE' });
116
+ }
117
+ const uid = ownUid();
118
+ if ((uid !== null && info.uid !== uid) || (info.mode & 0o022) !== 0) {
119
+ throw Object.assign(new Error(`${label} has unsafe ownership or mode`), { code: 'STATE_UNSAFE' });
120
+ }
121
+ }
122
+
123
+ /** Resolve the deepest existing ancestor before creating one component at a time. */
124
+ async function secureStateDirectory(env, components = [], { create = true } = {}) {
125
+ const requestedBase = stateBase(env);
126
+ let ancestor = requestedBase;
127
+ const missing = [];
128
+ while (true) {
129
+ try {
130
+ const info = await lstat(ancestor);
131
+ validateDirectory(info, ancestor);
132
+ break;
133
+ } catch (error) {
134
+ if (error?.code !== 'ENOENT') throw error;
135
+ if (ancestor === path.parse(ancestor).root) throw error;
136
+ missing.unshift(path.basename(ancestor));
137
+ ancestor = path.dirname(ancestor);
138
+ }
139
+ }
140
+ if (!create && missing.length > 0) return null;
141
+ const pinnedAncestor = await realpath(ancestor);
142
+ validateDirectory(await lstat(pinnedAncestor), pinnedAncestor);
143
+ let cursor = pinnedAncestor;
144
+ for (const component of missing) {
145
+ cursor = path.join(cursor, component);
146
+ try {
147
+ await mkdir(cursor, { mode: 0o700 });
148
+ await fsyncDir(path.dirname(cursor));
149
+ } catch (error) {
150
+ if (error?.code !== 'EEXIST') throw error;
151
+ }
152
+ validateDirectory(await lstat(cursor), cursor);
153
+ }
154
+ for (const component of components) {
155
+ cursor = path.join(cursor, component);
156
+ try {
157
+ validateDirectory(await lstat(cursor), cursor);
158
+ continue;
159
+ } catch (error) {
160
+ if (error?.code !== 'ENOENT') throw error;
161
+ if (!create) return null;
162
+ }
163
+ try {
164
+ await mkdir(cursor, { mode: 0o700 });
165
+ await fsyncDir(path.dirname(cursor));
166
+ } catch (error) {
167
+ if (error?.code !== 'EEXIST') throw error;
168
+ }
169
+ validateDirectory(await lstat(cursor), cursor);
170
+ }
171
+ return cursor;
172
+ }
173
+
174
+ function receiptFile(directory, host) {
175
+ return path.join(directory, 'installs', `${host}.json`);
176
+ }
177
+
178
+ function validateRegularOwnerMode(info, mode, code) {
179
+ const uid = ownUid();
180
+ if (!info.isFile() || (uid !== null && info.uid !== uid) || (info.mode & 0o777) !== mode) {
181
+ throw Object.assign(new Error('unsafe owned file'), { code });
182
+ }
183
+ }
184
+
185
+ async function readOwnedFile(target, { absent = null, code = 'UNSAFE_FILE' } = {}) {
186
+ let handle;
187
+ try {
188
+ handle = await open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
189
+ } catch (error) {
190
+ if (error?.code === 'ENOENT') return absent;
191
+ throw Object.assign(error, { code });
192
+ }
193
+ try {
194
+ validateRegularOwnerMode(await handle.stat(), 0o600, code);
195
+ return await handle.readFile();
196
+ } finally {
197
+ await handle.close();
198
+ }
199
+ }
200
+
201
+ function parseReceipt(bytes) {
202
+ if (bytes === null) return { schema: 'lattice.hooks_install_receipt.v1', entries: [] };
203
+ let value;
204
+ try { value = JSON.parse(bytes); } catch {
205
+ throw Object.assign(new Error('receipt is not JSON'), { code: 'INSTALL_RECEIPT_UNSAFE' });
206
+ }
207
+ if (value?.schema !== 'lattice.hooks_install_receipt.v1' || !Array.isArray(value.entries)
208
+ || value.entries.some((entry) => !Array.isArray(entry?.argv)
209
+ || !entry.argv.every((part) => typeof part === 'string')
210
+ || !['pending', 'committed'].includes(entry.status))) {
211
+ throw Object.assign(new Error('receipt shape is invalid'), { code: 'INSTALL_RECEIPT_UNSAFE' });
212
+ }
213
+ return value;
214
+ }
215
+
216
+ async function receiptLocation(env, host, create) {
217
+ const hooksDirectory = await secureStateDirectory(env, ['lattice', 'hooks'], { create });
218
+ if (hooksDirectory === null) return null;
219
+ const installs = path.join(hooksDirectory, 'installs');
220
+ if (create) {
221
+ try { await mkdir(installs, { mode: 0o700 }); await fsyncDir(hooksDirectory); } catch (error) {
222
+ if (error?.code !== 'EEXIST') throw error;
223
+ }
224
+ validateDirectory(await lstat(installs), installs);
225
+ } else {
226
+ try { validateDirectory(await lstat(installs), installs); } catch (error) {
227
+ if (error?.code === 'ENOENT') return null;
228
+ throw error;
229
+ }
230
+ }
231
+ return receiptFile(hooksDirectory, host);
232
+ }
233
+
234
+ async function readReceipt(env, host) {
235
+ const target = await receiptLocation(env, host, false);
236
+ if (target === null) return { target: null, value: parseReceipt(null) };
237
+ return {
238
+ target,
239
+ value: parseReceipt(await readOwnedFile(target, {
240
+ absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
241
+ })),
242
+ };
243
+ }
244
+
245
+ async function writeReceiptUnlocked(target, value) {
246
+ const directory = path.dirname(target);
247
+ const existing = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
248
+ if (existing !== null) parseReceipt(existing);
249
+ const tmp = `${target}.tmp-lattice-hooks-${unique()}`;
250
+ let handle;
251
+ try {
252
+ handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
253
+ | fsConstants.O_NOFOLLOW, 0o600);
254
+ await handle.chmod(0o600);
255
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
256
+ await handle.sync();
257
+ await handle?.close();
258
+ handle = null;
259
+ const beforeRename = await readOwnedFile(target, {
260
+ absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
261
+ });
262
+ if ((existing === null) !== (beforeRename === null)
263
+ || (existing !== null && !existing.equals(beforeRename))) {
264
+ throw Object.assign(new Error('receipt changed concurrently'), { code: 'INSTALL_RECEIPT_BUSY' });
265
+ }
266
+ await rename(tmp, target);
267
+ await fsyncDir(directory);
268
+ parseReceipt(await readOwnedFile(target, { code: 'INSTALL_RECEIPT_UNSAFE' }));
269
+ } finally {
270
+ await handle?.close().catch(() => {});
271
+ await removeArtifact(tmp).catch(() => {});
272
+ }
273
+ }
274
+
275
+ async function withReceiptLock(env, host, operation) {
276
+ const target = await receiptLocation(env, host, true);
277
+ const lockPath = `${target}.lock`;
278
+ let lock;
279
+ let acquired = false;
280
+ for (let attempt = 0; attempt < 60; attempt += 1) {
281
+ let created = false;
282
+ try {
283
+ lock = await open(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
284
+ | fsConstants.O_NOFOLLOW, 0o600);
285
+ created = true;
286
+ await lock.chmod(0o600);
287
+ await lock.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
288
+ await lock.sync();
289
+ await lock.close();
290
+ lock = null;
291
+ await fsyncDir(path.dirname(lockPath));
292
+ acquired = true;
293
+ break;
294
+ } catch (error) {
295
+ await lock?.close().catch(() => {});
296
+ lock = null;
297
+ if (created) await removeArtifact(lockPath).catch(() => {});
298
+ if (error?.code !== 'EEXIST') throw error;
299
+ try {
300
+ if (Date.now() - (await lstat(lockPath)).mtimeMs > RECEIPT_LOCK_MAX_AGE_MS) {
301
+ await unlink(lockPath);
302
+ await fsyncDir(path.dirname(lockPath));
303
+ continue;
304
+ }
305
+ } catch (statError) {
306
+ if (statError?.code === 'ENOENT') continue;
307
+ throw statError;
308
+ }
309
+ await new Promise((resolve) => setTimeout(resolve, 10));
310
+ }
311
+ }
312
+ if (!acquired) {
313
+ throw Object.assign(new Error('receipt lock unavailable'), { code: 'INSTALL_RECEIPT_BUSY' });
314
+ }
315
+ try {
316
+ const bytes = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
317
+ return await operation(parseReceipt(bytes), target);
318
+ } finally {
319
+ await unlink(lockPath).catch(() => {});
320
+ await fsyncDir(path.dirname(lockPath)).catch(() => {});
321
+ }
322
+ }
323
+
324
+ function allHandlers(value) {
325
+ const list = value?.hooks?.UserPromptSubmit;
326
+ if (!Array.isArray(list)) return [];
327
+ return list.flatMap((wrapper) => Array.isArray(wrapper?.hooks) ? wrapper.hooks : []);
328
+ }
329
+
330
+ function configContains(value, argv) {
331
+ return allHandlers(value).some((item) => item?.type === 'command'
332
+ && typeof item.command === 'string' && commandIs(item.command, [argv]));
333
+ }
334
+
335
+ async function recoverReceipt(env, host, configTarget, testHooks) {
336
+ const current = await readReceipt(env, host);
337
+ if (current.target === null || !current.value.entries.some((entry) => entry.status === 'pending')) {
338
+ return current.value;
339
+ }
340
+ return withReceiptLock(env, host, async (receipt, target) => {
341
+ await testHooks.afterReceiptLock?.({ configTarget });
342
+ const config = await readConfig(configTarget);
343
+ const entries = receipt.entries.flatMap((entry) => {
344
+ if (entry.status !== 'pending') return [entry];
345
+ return configContains(config.value, entry.argv) ? [{ ...entry, status: 'committed' }] : [];
346
+ });
347
+ const recovered = { ...receipt, entries };
348
+ await writeReceiptUnlocked(target, recovered);
349
+ return recovered;
350
+ });
351
+ }
352
+
353
+ async function appendPending(env, host, argv) {
354
+ const operationId = unique();
355
+ await withReceiptLock(env, host, async (receipt, target) => {
356
+ const entries = [...receipt.entries];
357
+ entries.push({ argv, status: 'pending', operation_id: operationId, recorded_at: new Date().toISOString() });
358
+ await writeReceiptUnlocked(target, { ...receipt, entries });
359
+ });
360
+ return operationId;
361
+ }
362
+
363
+ async function commitPending(env, host, operationId, argv) {
364
+ await withReceiptLock(env, host, async (receipt, target) => {
365
+ let found = false;
366
+ const entries = receipt.entries.map((entry) => {
367
+ if (entry.operation_id !== operationId) return entry;
368
+ found = true;
369
+ return { ...entry, status: 'committed' };
370
+ });
371
+ if (!found) entries.push({
372
+ argv, status: 'committed', operation_id: operationId, recorded_at: new Date().toISOString(),
373
+ });
374
+ await writeReceiptUnlocked(target, { ...receipt, entries });
375
+ });
376
+ }
377
+
378
+ async function readConfig(target) {
379
+ let info;
380
+ try { info = await lstat(target); } catch (error) {
381
+ if (error?.code === 'ENOENT') {
382
+ return { existed: false, mode: 0o600, bytes: Buffer.alloc(0), value: {} };
383
+ }
384
+ throw error;
385
+ }
386
+ if (info.isSymbolicLink()) throw Object.assign(new Error('config is symlink'), { code: 'SYMLINK' });
387
+ if (!info.isFile()) throw Object.assign(new Error('config is not regular'), { code: 'CONFIG_INVALID' });
388
+ const bytes = await readFile(target);
389
+ let value;
390
+ try { value = JSON.parse(bytes); } catch {
391
+ throw Object.assign(new Error('config is not JSON'), { code: 'CONFIG_INVALID' });
392
+ }
393
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
394
+ throw Object.assign(new Error('config root is not an object'), { code: 'CONFIG_INVALID' });
395
+ }
396
+ if (value.hooks !== undefined && (value.hooks === null || typeof value.hooks !== 'object'
397
+ || Array.isArray(value.hooks))) {
398
+ throw Object.assign(new Error('hooks is not an object'), { code: 'CONFIG_INVALID' });
399
+ }
400
+ if (value.hooks?.UserPromptSubmit !== undefined && !Array.isArray(value.hooks.UserPromptSubmit)) {
401
+ throw Object.assign(new Error('UserPromptSubmit is not an array'), { code: 'CONFIG_INVALID' });
402
+ }
403
+ return { existed: true, mode: info.mode & 0o777, bytes, value };
404
+ }
405
+
406
+ function hooksList(value) {
407
+ if (value.hooks === undefined) value.hooks = {};
408
+ if (value.hooks.UserPromptSubmit === undefined) value.hooks.UserPromptSubmit = [];
409
+ return value.hooks.UserPromptSubmit;
410
+ }
411
+
412
+ function stripIdentity(value, identities) {
413
+ let removed = 0;
414
+ const list = hooksList(value);
415
+ value.hooks.UserPromptSubmit = list.flatMap((wrapper) => {
416
+ if (!wrapper || !Array.isArray(wrapper.hooks)) return [wrapper];
417
+ const handlers = wrapper.hooks.filter((item) => {
418
+ const owned = item?.type === 'command' && typeof item.command === 'string'
419
+ && commandIs(item.command, identities);
420
+ if (owned) removed += 1;
421
+ return !owned;
422
+ });
423
+ return handlers.length > 0 ? [{ ...wrapper, hooks: handlers }] : [];
424
+ });
425
+ return removed;
426
+ }
427
+
428
+ function hostHandler(host, command) {
429
+ return host === 'claude'
430
+ ? { type: 'command', command, timeout: 5 }
431
+ : { type: 'command', command, timeout: 5, async: false, statusMessage: null };
432
+ }
433
+
434
+ export async function resolveStableNodePath(execPath, {
435
+ platform = process.platform,
436
+ accessImpl = access,
437
+ realpathImpl = realpath,
438
+ } = {}) {
439
+ await accessImpl(execPath, fsConstants.X_OK);
440
+ if (platform !== 'darwin') return execPath;
441
+ const match = execPath.match(/^\/(opt\/homebrew|usr\/local)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/u);
442
+ if (match === null) return execPath;
443
+ const prefix = `/${match[1]}`;
444
+ const candidates = [path.join(prefix, 'bin/node'), path.join(prefix, 'opt', match[2], 'bin/node')];
445
+ const resolvedExec = await realpathImpl(execPath);
446
+ for (const candidate of candidates) {
447
+ try {
448
+ await accessImpl(candidate, fsConstants.X_OK);
449
+ if (await realpathImpl(candidate) === resolvedExec) return candidate;
450
+ } catch {
451
+ // A candidate is usable only when it exists and resolves to this running Node binary.
452
+ }
453
+ }
454
+ return execPath;
455
+ }
456
+
457
+ async function resolveCanonical(host, source, platform) {
458
+ const sourcePaths = [source.execPath, source.binPath];
459
+ if (sourcePaths.some((entry) => typeof entry !== 'string' || !path.isAbsolute(entry)
460
+ || /[\0\r\n]/u.test(entry))) {
461
+ throw Object.assign(new Error('install source is not absolute'), { code: 'INSTALL_SOURCE_UNRESOLVED' });
462
+ }
463
+ try {
464
+ const executable = await resolveStableNodePath(source.execPath, { platform });
465
+ const script = await realpath(source.binPath);
466
+ if (/[\0\r\n]/u.test(script)) throw new Error('resolved install source has unsafe characters');
467
+ await access(script, fsConstants.R_OK | fsConstants.X_OK);
468
+ return [executable, script, 'hooks', 'emit', '--host', host];
469
+ } catch (error) {
470
+ throw Object.assign(error, { code: 'INSTALL_SOURCE_UNRESOLVED' });
471
+ }
472
+ }
473
+
474
+ async function createBackup(target, prestate) {
475
+ if (!prestate.existed) return null;
476
+ const ref = `${target}.bak-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
477
+ let handle;
478
+ try {
479
+ handle = await open(ref, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
480
+ | fsConstants.O_NOFOLLOW, 0o600);
481
+ await handle.chmod(0o600);
482
+ await handle.writeFile(prestate.bytes);
483
+ await handle.sync();
484
+ await handle.close();
485
+ handle = null;
486
+ await fsyncDir(path.dirname(target));
487
+ return ref;
488
+ } catch (error) {
489
+ await handle?.close().catch(() => {});
490
+ await removeArtifact(ref).catch(() => {});
491
+ throw error;
492
+ }
493
+ }
494
+
495
+ async function removeArtifact(target) {
496
+ if (target === null) return;
497
+ try { await unlink(target); await fsyncDir(path.dirname(target)); } catch (error) {
498
+ if (error?.code !== 'ENOENT') throw error;
499
+ }
500
+ }
501
+
502
+ async function pruneGenerations(target) {
503
+ const directory = path.dirname(target);
504
+ const basename = path.basename(target);
505
+ for (const marker of ['bak-lattice-hooks-', 'pre-lattice-hooks-']) {
506
+ const prefix = `${basename}.${marker}`;
507
+ const entries = [];
508
+ for (const name of await readdir(directory)) {
509
+ if (!name.startsWith(prefix)) continue;
510
+ const full = path.join(directory, name);
511
+ try { entries.push({ full, mtimeMs: (await lstat(full)).mtimeMs }); } catch (error) {
512
+ if (error?.code !== 'ENOENT') throw error;
513
+ }
514
+ }
515
+ entries.sort((left, right) => right.mtimeMs - left.mtimeMs || right.full.localeCompare(left.full));
516
+ for (const entry of entries.slice(5)) await removeArtifact(entry.full);
517
+ }
518
+ }
519
+
520
+ async function writeTmp(target, bytes, mode, tag = 'tmp') {
521
+ const tmp = `${target}.${tag}-lattice-hooks-${unique()}`;
522
+ let handle;
523
+ try {
524
+ handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
525
+ | fsConstants.O_NOFOLLOW, mode);
526
+ await handle.chmod(mode);
527
+ await handle.writeFile(bytes);
528
+ await handle.sync();
529
+ await handle.close();
530
+ handle = null;
531
+ return tmp;
532
+ } catch (error) {
533
+ await handle?.close().catch(() => {});
534
+ await removeArtifact(tmp).catch(() => {});
535
+ throw error;
536
+ }
537
+ }
538
+
539
+ async function restoreExisting(target, displaced, mode, testHooks) {
540
+ await testHooks.beforeRestore?.({ target, displaced });
541
+ const bytes = await readFile(displaced);
542
+ const tmp = await writeTmp(target, bytes, mode, 'restore');
543
+ try {
544
+ await rename(tmp, target);
545
+ await fsyncDir(path.dirname(target));
546
+ if (!(await readFile(target)).equals(bytes)) throw new Error('restore read-back mismatch');
547
+ } finally {
548
+ await unlink(tmp).catch(() => {});
549
+ }
550
+ }
551
+
552
+ async function rollbackAbsent(target, tmp) {
553
+ const targetInfo = await lstat(target);
554
+ const tmpInfo = await lstat(tmp);
555
+ if (targetInfo.dev !== tmpInfo.dev || targetInfo.ino !== tmpInfo.ino) {
556
+ throw new Error('created target was replaced before rollback');
557
+ }
558
+ await unlink(target);
559
+ await fsyncDir(path.dirname(target));
560
+ try { await lstat(target); throw new Error('absent rollback read-back failed'); } catch (error) {
561
+ if (error?.code !== 'ENOENT') throw error;
562
+ }
563
+ }
564
+
565
+ async function commitConfig(target, prestate, value, testHooks) {
566
+ const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
567
+ JSON.parse(serialized);
568
+ const tmp = await writeTmp(target, serialized, prestate.existed ? prestate.mode : 0o600);
569
+ let displaced = null;
570
+ let committed = false;
571
+ let complete = false;
572
+ try {
573
+ if (prestate.existed) {
574
+ await testHooks.beforePreimageVerify?.({ target, prestate });
575
+ const current = await readConfig(target);
576
+ if (!current.existed || !current.bytes.equals(prestate.bytes)) {
577
+ throw Object.assign(new Error('preimage changed'), { code: 'PREIMAGE_CHANGED' });
578
+ }
579
+ displaced = `${target}.pre-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
580
+ await link(target, displaced);
581
+ await fsyncDir(path.dirname(target));
582
+ await testHooks.afterDisplacedLink?.({ target, displaced });
583
+ const before = await lstat(target);
584
+ const saved = await lstat(displaced);
585
+ if (!before.isFile() || before.dev !== saved.dev || before.ino !== saved.ino) {
586
+ throw Object.assign(new Error('target inode changed'), { code: 'PREIMAGE_CHANGED' });
587
+ }
588
+ await rename(tmp, target);
589
+ committed = true;
590
+ await fsyncDir(path.dirname(target));
591
+ } else {
592
+ try { await link(tmp, target); } catch (error) {
593
+ if (error?.code === 'EEXIST') {
594
+ throw Object.assign(error, { code: 'PREIMAGE_CHANGED' });
595
+ }
596
+ throw error;
597
+ }
598
+ committed = true;
599
+ await fsyncDir(path.dirname(target));
600
+ }
601
+ await testHooks.beforeConfigReadBack?.({ target, serialized, displaced });
602
+ if (!(await readFile(target)).equals(serialized)) throw new Error('config read-back mismatch');
603
+ complete = true;
604
+ return displaced;
605
+ } catch (error) {
606
+ if (committed) {
607
+ try {
608
+ if (prestate.existed) await restoreExisting(target, displaced, prestate.mode, testHooks);
609
+ else await rollbackAbsent(target, tmp);
610
+ } catch (restoreError) {
611
+ throw Object.assign(new Error(`restore failed: ${restoreError.message}`), {
612
+ code: 'RESTORE_FAILED', cause: error, commitOccurred: true, displacedPath: displaced,
613
+ });
614
+ }
615
+ Object.assign(error, { commitOccurred: true, displacedPath: displaced });
616
+ }
617
+ throw error;
618
+ } finally {
619
+ await removeArtifact(tmp).catch(() => {});
620
+ if (!complete && !committed) await removeArtifact(displaced).catch(() => {});
621
+ }
622
+ }
623
+
624
+ function committedIdentities(receipt) {
625
+ return receipt.entries.filter((entry) => entry.status === 'committed').map((entry) => entry.argv);
626
+ }
627
+
628
+ function exactHandler(item, expected) {
629
+ return JSON.stringify(item) === JSON.stringify(expected);
630
+ }
631
+
632
+ async function hostDirectory(home, host) {
633
+ const directory = path.dirname(configPath(home, host));
634
+ try {
635
+ const info = await lstat(directory);
636
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('not a directory');
637
+ return directory;
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+
643
+ async function mutate(host, env, stdout, uninstall, source, platform, testHooks) {
644
+ const home = env.HOME ?? os.homedir();
645
+ if (await hostDirectory(home, host) === null) {
646
+ return failure(stdout, 'HOST_NOT_PRESENT', 'host home directory is not present');
647
+ }
648
+ let current;
649
+ try { current = await resolveCanonical(host, source, platform); } catch {
650
+ return failure(stdout, 'INSTALL_SOURCE_UNRESOLVED', 'install source cannot be resolved');
651
+ }
652
+ const target = configPath(home, host);
653
+ for (let attempt = 0; attempt < 2; attempt += 1) {
654
+ let prestate;
655
+ try { prestate = await readConfig(target); } catch (error) {
656
+ return failure(stdout, error?.code === 'SYMLINK' ? 'CONFIG_SYMLINK_UNSUPPORTED'
657
+ : 'CONFIG_UNREADABLE', 'configuration cannot be read');
658
+ }
659
+ let receipt;
660
+ try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
661
+ return failure(stdout, 'INSTALL_RECEIPT_UNSAFE', 'install receipt cannot be safely read');
662
+ }
663
+ const identities = [current, ...committedIdentities(receipt)];
664
+ const next = structuredClone(prestate.value);
665
+ const removed = stripIdentity(next, identities);
666
+ if (!uninstall) hooksList(next).push({ hooks: [hostHandler(host, shell(current))] });
667
+
668
+ const currentHandlers = allHandlers(prestate.value).filter((item) => item?.type === 'command'
669
+ && typeof item.command === 'string' && commandIs(item.command, identities));
670
+ const alreadyWired = !uninstall && currentHandlers.length === 1
671
+ && commandIs(currentHandlers[0].command, [current])
672
+ && exactHandler(currentHandlers[0], hostHandler(host, shell(current)));
673
+ if ((uninstall && removed === 0) || alreadyWired) {
674
+ writeJson(stdout, uninstall
675
+ ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: 0 }
676
+ : { schema: 'lattice.hooks_install_result.v1', host, state: 'already_wired' });
677
+ return 0;
678
+ }
679
+
680
+ let pendingId = null;
681
+ let backup = null;
682
+ let configCommitted = false;
683
+ try {
684
+ if (!uninstall) pendingId = await appendPending(env, host, current);
685
+ backup = await createBackup(target, prestate);
686
+ await commitConfig(target, prestate, next, testHooks);
687
+ configCommitted = true;
688
+ if (!uninstall) await commitPending(env, host, pendingId, current);
689
+ let warning;
690
+ try {
691
+ await testHooks.beforePrune?.({ target });
692
+ await pruneGenerations(target);
693
+ } catch (error) {
694
+ warning = { code: 'GENERATION_PRUNE_FAILED', message: error?.message ?? 'generation prune failed' };
695
+ }
696
+ const result = uninstall
697
+ ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: removed }
698
+ : { schema: 'lattice.hooks_install_result.v1', host, state: 'wired' };
699
+ if (warning !== undefined) result.warning = warning;
700
+ writeJson(stdout, result);
701
+ return 0;
702
+ } catch (error) {
703
+ if (!configCommitted && !error?.commitOccurred) await removeArtifact(backup).catch(() => {});
704
+ if (!configCommitted && error?.code === 'PREIMAGE_CHANGED' && attempt === 0) continue;
705
+ const code = error?.code === 'RESTORE_FAILED' ? 'RESTORE_FAILED'
706
+ : ['INSTALL_RECEIPT_UNSAFE', 'INSTALL_RECEIPT_BUSY'].includes(error?.code)
707
+ ? 'INSTALL_RECEIPT_UNSAFE'
708
+ : 'CONFIG_WRITE_FAILED';
709
+ const detail = code === 'RESTORE_FAILED' ? {
710
+ backup_path: backup,
711
+ displaced_path: error.displacedPath,
712
+ } : undefined;
713
+ return failure(stdout, code, 'configuration cannot be safely written', 1, detail);
714
+ }
715
+ }
716
+ return failure(stdout, 'CONFIG_WRITE_FAILED', 'configuration changed concurrently');
717
+ }
718
+
719
+ function statusResult(host, target, canonicalCommand, state, matches, executableOk,
720
+ foreignCandidateCount) {
721
+ return {
722
+ schema: 'lattice.hooks_status_result.v1',
723
+ host,
724
+ config_path: target,
725
+ state,
726
+ canonical_command: canonicalCommand,
727
+ matched_handler_count: matches,
728
+ foreign_candidate_count: foreignCandidateCount,
729
+ executable_ok: executableOk,
730
+ next_action: state === 'wired' ? null : `lattice hooks install --host ${host}`,
731
+ };
732
+ }
733
+
734
+ async function status(host, env, stdout, source, platform, testHooks) {
735
+ const home = env.HOME ?? os.homedir();
736
+ const target = configPath(home, host);
737
+ let argv;
738
+ try { argv = await resolveCanonical(host, source, platform); } catch {
739
+ writeJson(stdout, statusResult(host, target, null, 'unreadable', 0, false, 0));
740
+ return 1;
741
+ }
742
+ if (platform === 'win32' || await hostDirectory(home, host) === null) {
743
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
744
+ return 1;
745
+ }
746
+ let config;
747
+ try { config = await readConfig(target); } catch {
748
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
749
+ return 1;
750
+ }
751
+ let receipt;
752
+ try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
753
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
754
+ return 1;
755
+ }
756
+ try { config = await readConfig(target); } catch {
757
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
758
+ return 1;
759
+ }
760
+ const identities = [argv, ...committedIdentities(receipt)];
761
+ let matches = 0;
762
+ let canonicalMatches = 0;
763
+ let foreign = 0;
764
+ let canonicalShape = false;
765
+ for (const item of allHandlers(config.value)) {
766
+ if (item?.type !== 'command' || typeof item.command !== 'string') continue;
767
+ if (commandIs(item.command, identities)) {
768
+ matches += 1;
769
+ if (commandIs(item.command, [argv])) {
770
+ canonicalMatches += 1;
771
+ canonicalShape = exactHandler(item, hostHandler(host, shell(argv)));
772
+ }
773
+ } else if (emitCandidate(item.command, host)) foreign += 1;
774
+ }
775
+ const executableOk = await Promise.all(argv.slice(0, 2).map(async (entry) => {
776
+ try { await access(entry, fsConstants.X_OK); return (await stat(entry)).isFile(); } catch { return false; }
777
+ })).then((values) => values.every(Boolean));
778
+ let state = 'drift';
779
+ if (matches === 0 && foreign === 0) state = 'not_wired';
780
+ else if (matches === 1 && canonicalMatches === 1 && canonicalShape && executableOk && foreign === 0) {
781
+ state = 'wired';
782
+ }
783
+ writeJson(stdout, statusResult(host, target, shell(argv), state, matches, executableOk, foreign));
784
+ return 0;
785
+ }
786
+
787
+ async function appendError(state, message) {
788
+ const target = path.join(state, 'errors.log');
789
+ try {
790
+ const created = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT
791
+ | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
792
+ try { await created.chmod(0o600); await created.sync(); } finally { await created.close(); }
793
+ await fsyncDir(state);
794
+ } catch (error) {
795
+ if (error?.code !== 'EEXIST') throw error;
796
+ }
797
+ const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
798
+ | fsConstants.O_NOFOLLOW, 0o600);
799
+ try {
800
+ validateRegularOwnerMode(await handle.stat(), 0o600, 'ERROR_LOG_UNSAFE');
801
+ await handle.writeFile(`${new Date().toISOString()} ${message}\n`);
802
+ await handle.sync();
803
+ } finally { await handle.close(); }
804
+ }
805
+
806
+ async function recordOrDiagnose(state, stdout, message, diagnostic) {
807
+ try { await appendError(state, message); } catch { stdout.write(`Lattice hooks: ${diagnostic}\n`); }
808
+ }
809
+
810
+ async function diagnose(state, stdout, message, diagnostic) {
811
+ try { await appendError(state, message); } catch {}
812
+ stdout.write(`Lattice hooks: ${diagnostic}\n`);
813
+ }
814
+
815
+ async function readHookInput(stdin) {
816
+ const chunks = [];
817
+ let length = 0;
818
+ let tooLarge = false;
819
+ for await (const chunk of stdin) {
820
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
821
+ length += bytes.length;
822
+ if (length <= MAX_STDIN_BYTES) chunks.push(bytes);
823
+ else tooLarge = true;
824
+ }
825
+ if (tooLarge) throw new Error('hook stdin exceeds 64KiB');
826
+ let event;
827
+ try { event = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
828
+ throw new Error('hook stdin is not strict JSON');
829
+ }
830
+ if (typeof event?.session_id !== 'string' || event.session_id.length === 0
831
+ || typeof event.cwd !== 'string' || !path.isAbsolute(event.cwd)) {
832
+ throw new Error('hook stdin requires session_id and absolute cwd');
833
+ }
834
+ try {
835
+ const cwd = await realpath(event.cwd);
836
+ if (!(await lstat(cwd)).isDirectory()) throw new Error('cwd is not a directory');
837
+ return { ...event, cwd };
838
+ } catch {
839
+ throw new Error('hook cwd is unavailable');
840
+ }
841
+ }
842
+
843
+ async function gitRoot(cwd, spawnImpl, timeoutMs) {
844
+ return new Promise((resolve) => {
845
+ let settled = false;
846
+ let out = '';
847
+ let child;
848
+ const finish = (result) => {
849
+ if (settled) return;
850
+ settled = true;
851
+ clearTimeout(timer);
852
+ resolve(result);
853
+ };
854
+ try {
855
+ child = spawnImpl('git', ['--no-optional-locks', '-C', cwd, 'rev-parse', '--show-toplevel'], {
856
+ shell: false, stdio: ['ignore', 'pipe', 'ignore'],
857
+ });
858
+ } catch (error) {
859
+ resolve({ kind: 'error', error });
860
+ return;
861
+ }
862
+ const timer = setTimeout(() => {
863
+ child.kill('SIGKILL');
864
+ finish({ kind: 'error', error: new Error('git root lookup timed out') });
865
+ }, timeoutMs);
866
+ child.once('error', (error) => finish({ kind: 'error', error }));
867
+ child.stdout.on('error', (error) => finish({ kind: 'error', error }));
868
+ child.stdout.on('data', (data) => {
869
+ out += data;
870
+ if (Buffer.byteLength(out) > MAX_STDIN_BYTES) {
871
+ child.kill('SIGKILL');
872
+ finish({ kind: 'error', error: new Error('git output too large') });
873
+ }
874
+ });
875
+ child.once('close', (code, signal) => {
876
+ if (code !== 0) {
877
+ finish(signal === null ? { kind: 'not_git' }
878
+ : { kind: 'error', error: new Error(`git terminated by ${signal}`) });
879
+ return;
880
+ }
881
+ const root = out.trim();
882
+ finish(path.isAbsolute(root) ? { kind: 'root', root }
883
+ : { kind: 'error', error: new Error('git returned a non-absolute root') });
884
+ });
885
+ });
886
+ }
887
+
888
+ const ownNotificationPattern = /^[a-f0-9]{64}\.[a-f0-9]{64}\.(?:shown|claim)$/u;
889
+
890
+ async function gcNotifications(state) {
891
+ const now = Date.now();
892
+ for (const name of await readdir(state)) {
893
+ if (!ownNotificationPattern.test(name)) continue;
894
+ const target = path.join(state, name);
895
+ let info;
896
+ try { info = await lstat(target); } catch (error) {
897
+ if (error?.code === 'ENOENT') continue;
898
+ throw error;
899
+ }
900
+ const maximum = name.endsWith('.claim') ? CLAIM_MAX_AGE_MS : SHOWN_MAX_AGE_MS;
901
+ if (now - info.mtimeMs > maximum) await removeArtifact(target);
902
+ }
903
+ }
904
+
905
+ async function freshShown(shown) {
906
+ try { return Date.now() - (await lstat(shown)).mtimeMs <= SHOWN_MAX_AGE_MS; } catch (error) {
907
+ if (error?.code === 'ENOENT') return false;
908
+ throw error;
909
+ }
910
+ }
911
+
912
+ async function acquireClaim(claim) {
913
+ let handle;
914
+ let created = false;
915
+ try {
916
+ handle = await open(claim, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
917
+ | fsConstants.O_NOFOLLOW, 0o600);
918
+ created = true;
919
+ await handle.chmod(0o600);
920
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
921
+ await handle.sync();
922
+ await handle.close();
923
+ handle = null;
924
+ await fsyncDir(path.dirname(claim));
925
+ return true;
926
+ } catch (error) {
927
+ await handle?.close().catch(() => {});
928
+ if (created) await removeArtifact(claim).catch(() => {});
929
+ if (error?.code === 'EEXIST') return false;
930
+ throw error;
931
+ }
932
+ }
933
+
934
+ function outputLine(host) {
935
+ return host === 'claude' ? `${INFO}\n` : `${JSON.stringify({
936
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: INFO },
937
+ })}\n`;
938
+ }
939
+
940
+ async function writeOutput(stdout, line) {
941
+ await new Promise((resolve, reject) => {
942
+ try { stdout.write(line, (error) => error ? reject(error) : resolve()); } catch (error) { reject(error); }
943
+ });
944
+ }
945
+
946
+ async function emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks) {
947
+ if (env.LATTICE_HOOKS === 'off') return 0;
948
+ let state;
949
+ try { state = await secureStateDirectory(env, ['lattice', 'hooks']); } catch {
950
+ stdout.write('Lattice hooks: state directory unavailable\n');
951
+ return 0;
952
+ }
953
+ let event;
954
+ try { event = await readHookInput(stdin); } catch (error) {
955
+ await recordOrDiagnose(state, stdout, error.message, 'cannot record invalid stdin');
956
+ return 0;
957
+ }
958
+ const git = await gitRoot(event.cwd, spawnImpl, gitTimeoutMs);
959
+ if (git.kind === 'not_git') return 0;
960
+ if (git.kind === 'error') {
961
+ await diagnose(state, stdout, git.error.message, 'git root lookup failed');
962
+ return 0;
963
+ }
964
+ const sensor = path.join(git.root, '.lattice', 'sensor');
965
+ try {
966
+ if (!(await lstat(sensor)).isDirectory()) return 0;
967
+ } catch (error) {
968
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return 0;
969
+ await diagnose(state, stdout, `sensor lookup failed: ${error.message}`, 'sensor index unavailable');
970
+ return 0;
971
+ }
972
+ try { await gcNotifications(state); } catch (error) {
973
+ await diagnose(state, stdout, `notification gc failed: ${error.message}`, 'notification state unavailable');
974
+ return 0;
975
+ }
976
+ const key = `${hash(event.session_id)}.${hash(git.root)}`;
977
+ const shown = path.join(state, `${key}.shown`);
978
+ const claim = path.join(state, `${key}.claim`);
979
+ try {
980
+ if (await freshShown(shown)) return 0;
981
+ } catch (error) {
982
+ await diagnose(state, stdout, `shown precheck failed: ${error.message}`, 'notification state unavailable');
983
+ return 0;
984
+ }
985
+ try {
986
+ if (!await acquireClaim(claim)) return 0;
987
+ } catch (error) {
988
+ await diagnose(state, stdout, `claim failed: ${error.message}`, 'notification claim unavailable');
989
+ return 0;
990
+ }
991
+ try {
992
+ if (await freshShown(shown)) {
993
+ await removeArtifact(claim);
994
+ return 0;
995
+ }
996
+ } catch (error) {
997
+ await removeArtifact(claim).catch(() => {});
998
+ await diagnose(state, stdout, `shown recheck failed: ${error.message}`, 'notification state unavailable');
999
+ return 0;
1000
+ }
1001
+ try {
1002
+ await writeOutput(stdout, outputLine(host));
1003
+ } catch (error) {
1004
+ await removeArtifact(claim).catch(() => {});
1005
+ await recordOrDiagnose(state, stdout, `notification output failed: ${error.message}`,
1006
+ 'notification output failed');
1007
+ return 0;
1008
+ }
1009
+ try {
1010
+ await testHooks.beforeShownRename?.({ claim, shown });
1011
+ await rename(claim, shown);
1012
+ await fsyncDir(state);
1013
+ } catch (error) {
1014
+ await recordOrDiagnose(state, stdout, `claim promotion failed: ${error.message}`,
1015
+ 'notification record failed');
1016
+ try {
1017
+ const handle = await open(shown, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
1018
+ | fsConstants.O_NOFOLLOW, 0o600);
1019
+ await handle.chmod(0o600);
1020
+ await handle.sync();
1021
+ await handle.close();
1022
+ await fsyncDir(state);
1023
+ } catch (fallbackError) {
1024
+ await recordOrDiagnose(state, stdout, `shown fallback failed: ${fallbackError.message}`,
1025
+ 'notification record failed');
1026
+ }
1027
+ await removeArtifact(claim).catch(() => {});
1028
+ }
1029
+ return 0;
1030
+ }
1031
+
1032
+ export async function runHooksCli({
1033
+ argv,
1034
+ stdout,
1035
+ stdin = process.stdin,
1036
+ env = process.env,
1037
+ platform = process.platform,
1038
+ source = { execPath: process.execPath, binPath },
1039
+ spawnImpl = spawn,
1040
+ gitTimeoutMs = 2000,
1041
+ testHooks = {},
1042
+ }) {
1043
+ if (argv.length !== 3 || !['install', 'status', 'uninstall', 'emit'].includes(argv[0])
1044
+ || argv[1] !== '--host' || !HOSTS.has(argv[2])) {
1045
+ return failure(stdout, 'USAGE',
1046
+ 'usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>', 2);
1047
+ }
1048
+ const [command, , host] = argv;
1049
+ if (platform === 'win32') {
1050
+ if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1051
+ return failure(stdout, 'HOST_PLATFORM_UNSUPPORTED', 'native Windows hooks are unsupported');
1052
+ }
1053
+ if (command === 'install') return mutate(host, env, stdout, false, source, platform, testHooks);
1054
+ if (command === 'uninstall') return mutate(host, env, stdout, true, source, platform, testHooks);
1055
+ if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1056
+ return emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks);
1057
+ }