@quolu/lattice 0.52.2 → 0.52.4

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 (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +699 -697
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +3143 -3141
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +4360 -4322
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. 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
+ }