@quolu/lattice 0.52.3 → 0.53.0

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 (188) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-bridge.mjs +25 -0
  5. package/bin/lattice-hub.mjs +67 -0
  6. package/bin/lattice-mcp.mjs +0 -0
  7. package/bin/lattice-scripted-adapter.mjs +0 -0
  8. package/bin/lattice-scripted-worker.mjs +0 -0
  9. package/bin/lattice-work-order-adapter.mjs +0 -0
  10. package/bin/lattice.mjs +0 -0
  11. package/docs/bridge-setup.md +132 -132
  12. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  13. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  14. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  15. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  16. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  17. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  18. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  19. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  20. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  21. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  22. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  23. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  24. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  25. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  26. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  27. package/package.json +103 -103
  28. package/sensor/LICENSE +21 -21
  29. package/sensor/NOTICE +19 -19
  30. package/sensor/dist/bin/lattice-sensor.js +9 -9
  31. package/sensor/dist/db/index.js +24 -24
  32. package/sensor/dist/db/migrations.js +41 -41
  33. package/sensor/dist/db/queries.js +164 -164
  34. package/sensor/dist/db/schema.sql +205 -205
  35. package/sensor/dist/directory.js +5 -5
  36. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  49. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  50. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  51. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  52. package/sensor/dist/mcp/server-instructions.js +95 -95
  53. package/sensor/package.json +56 -56
  54. package/src/artifact-contracts-v2.mjs +325 -325
  55. package/src/artifact-contracts.mjs +895 -895
  56. package/src/boundary-compiler.mjs +712 -712
  57. package/src/boundary-observation-compiler-v2.mjs +344 -344
  58. package/src/bounded-seam.mjs +230 -230
  59. package/src/bridge-address.mjs +107 -107
  60. package/src/bridge-cli.mjs +303 -297
  61. package/src/bridge-config.mjs +382 -346
  62. package/src/bridge-daemon.mjs +378 -378
  63. package/src/bridge-hub-heartbeat.mjs +170 -0
  64. package/src/bridge-hub-protocol.mjs +198 -0
  65. package/src/bridge-hub-server.mjs +544 -0
  66. package/src/bridge-launch-agent.mjs +323 -323
  67. package/src/bridge-registrar.mjs +102 -102
  68. package/src/bridge-server.mjs +376 -376
  69. package/src/cli-help.mjs +308 -308
  70. package/src/cli-stdio.mjs +40 -40
  71. package/src/control-compiler.mjs +532 -532
  72. package/src/dag-chain.mjs +261 -261
  73. package/src/factory-diagnostics.mjs +188 -188
  74. package/src/git-process.mjs +72 -0
  75. package/src/hash-chain.mjs +76 -76
  76. package/src/hooks-cli.mjs +1057 -1057
  77. package/src/isolation-runner.mjs +409 -409
  78. package/src/node-version-guard.mjs +44 -44
  79. package/src/project-cli.mjs +3 -3
  80. package/src/project-identity.mjs +130 -130
  81. package/src/rc1-black-box-oracle.mjs +906 -906
  82. package/src/rc1-comparison.mjs +154 -154
  83. package/src/rc1-evidence-bundle.mjs +456 -456
  84. package/src/rc1-v4-campaign.mjs +708 -708
  85. package/src/rc1-v4-transform.mjs +467 -467
  86. package/src/rc1-v5-artifact-set.mjs +855 -855
  87. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  88. package/src/rc1-v5-campaign.mjs +797 -797
  89. package/src/rc1-v5-transform.mjs +421 -421
  90. package/src/rc1-v6-artifact-set.mjs +807 -807
  91. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  92. package/src/rc1-v6-campaign.mjs +623 -623
  93. package/src/rc1-v6-causal-binding.mjs +473 -473
  94. package/src/rc1-v6-measurement.mjs +313 -313
  95. package/src/rc2-artifact-set.mjs +1584 -1584
  96. package/src/rc2-campaign.mjs +1506 -1506
  97. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  98. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  99. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  100. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  101. package/src/rc3-actual-dogfood.mjs +652 -652
  102. package/src/rc3-dogfood-scaffold.mjs +315 -315
  103. package/src/rc3-scripted-campaign.mjs +1383 -1383
  104. package/src/rc4-stage1-dogfood.mjs +673 -673
  105. package/src/runtime-adapter-registry.mjs +524 -524
  106. package/src/runtime-cli.mjs +4588 -4588
  107. package/src/runtime-contracts.mjs +824 -824
  108. package/src/runtime-control-store.mjs +604 -604
  109. package/src/runtime-controller-protocol.mjs +587 -587
  110. package/src/runtime-decision-verifier.mjs +701 -701
  111. package/src/runtime-diff-observer.mjs +361 -361
  112. package/src/runtime-direct-os-observer.mjs +301 -301
  113. package/src/runtime-driver-state.mjs +166 -166
  114. package/src/runtime-engine.mjs +779 -779
  115. package/src/runtime-errors.mjs +356 -356
  116. package/src/runtime-event-store.mjs +189 -189
  117. package/src/runtime-front-end.mjs +925 -925
  118. package/src/runtime-gate-store.mjs +481 -481
  119. package/src/runtime-hold-recompile.mjs +916 -916
  120. package/src/runtime-io-sentinel.mjs +391 -391
  121. package/src/runtime-lifecycle-lock.mjs +294 -294
  122. package/src/runtime-managed-supervisor.mjs +1490 -1490
  123. package/src/runtime-multi-epoch-store.mjs +838 -838
  124. package/src/runtime-projection.mjs +269 -269
  125. package/src/runtime-pull-intake.mjs +1192 -1192
  126. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  127. package/src/runtime-scripted-executor.mjs +163 -163
  128. package/src/runtime-scripted-worktree.mjs +104 -104
  129. package/src/runtime-seam-resolve.mjs +428 -428
  130. package/src/runtime-seam-treatment.mjs +173 -173
  131. package/src/runtime-socket-owner.mjs +125 -125
  132. package/src/runtime-work-order-contracts.mjs +91 -91
  133. package/src/runtime-work-order-controller.mjs +1171 -1171
  134. package/src/runtime-worktree-executor.mjs +199 -199
  135. package/src/schedulability-compiler-v2.mjs +303 -303
  136. package/src/schedulability-verifier-v2.mjs +317 -317
  137. package/src/seam-apply.mjs +549 -549
  138. package/src/seam-commit-shared.mjs +22 -22
  139. package/src/seam-commit-transform.mjs +81 -81
  140. package/src/seam-commit.mjs +18 -18
  141. package/src/seam-cost.mjs +322 -322
  142. package/src/seam-derivation.mjs +188 -188
  143. package/src/seam-gate.mjs +146 -146
  144. package/src/seam-proposal-contracts.mjs +446 -446
  145. package/src/seam-proposal-queries.mjs +521 -521
  146. package/src/seam-proposal.mjs +2011 -2011
  147. package/src/seam-ref.mjs +33 -33
  148. package/src/seam-rewrite.mjs +286 -286
  149. package/src/seam-transform.mjs +554 -554
  150. package/src/seam-verification.mjs +260 -260
  151. package/src/sensor-adapter.mjs +432 -432
  152. package/src/sensor-cli.mjs +139 -139
  153. package/src/sensor-diff.mjs +661 -661
  154. package/src/sensor-node-runtime.mjs +53 -53
  155. package/src/sensor-runtime.mjs +52 -52
  156. package/src/timestamp-contract.mjs +8 -8
  157. package/src/todo-audit-pending.mjs +91 -91
  158. package/src/todo-chain.mjs +178 -178
  159. package/src/todo-cli.mjs +8 -8
  160. package/src/todo-contracts.mjs +728 -728
  161. package/src/todo-dashboard-registry.mjs +573 -573
  162. package/src/todo-dispatch-shape.mjs +190 -190
  163. package/src/todo-gantt-html-independence.mjs +239 -239
  164. package/src/todo-gantt-html-shared.mjs +226 -226
  165. package/src/todo-gantt-html-style.mjs +131 -131
  166. package/src/todo-gantt-html.mjs +248 -248
  167. package/src/todo-gantt-layout.mjs +974 -974
  168. package/src/todo-gantt-live.mjs +361 -361
  169. package/src/todo-gantt-nested.mjs +263 -263
  170. package/src/todo-gantt-presentation.mjs +217 -217
  171. package/src/todo-gantt-scope.mjs +123 -123
  172. package/src/todo-gantt-svg.mjs +353 -353
  173. package/src/todo-independence-contracts.mjs +595 -595
  174. package/src/todo-independence-guidance.mjs +322 -322
  175. package/src/todo-independence.mjs +640 -640
  176. package/src/todo-markdown-renderer.mjs +260 -260
  177. package/src/todo-migration.mjs +448 -448
  178. package/src/todo-narrative-anchor.mjs +130 -130
  179. package/src/todo-note-store.mjs +629 -629
  180. package/src/todo-parallel-candidates.mjs +114 -114
  181. package/src/todo-revision.mjs +995 -995
  182. package/src/todo-split.mjs +472 -472
  183. package/src/todo-status.mjs +690 -690
  184. package/src/todo-store-git-transaction.mjs +418 -418
  185. package/src/todo-store.mjs +42 -21
  186. package/src/treatment-compiler.mjs +728 -728
  187. package/src/treatment-runner.mjs +656 -656
  188. package/src/witness-scaffold.mjs +180 -180
package/src/hooks-cli.mjs CHANGED
@@ -1,1057 +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
- // 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
- }
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
+ }