@quolu/lattice 0.39.2 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lattice.mjs CHANGED
@@ -90,6 +90,11 @@ if (help !== null) {
90
90
  process.exitCode = await runBridgeCli({
91
91
  argv: args.slice(1), stdout: process.stdout, stderr: process.stderr, env: process.env,
92
92
  });
93
+ } else if (args[0] === 'hooks') {
94
+ const { runHooksCli } = await import('../src/hooks-cli.mjs');
95
+ process.exitCode = await runHooksCli({
96
+ argv: args.slice(1), stdout: process.stdout, stdin: process.stdin, env: process.env,
97
+ });
93
98
  } else {
94
99
  try {
95
100
  process.exitCode = await runRuntimeCli({
@@ -250,8 +250,8 @@
250
250
  "from_task_id": { "$ref": "#/$defs/identifier" },
251
251
  "to_task_id": { "oneOf": [{ "const": "removed" }, { "$ref": "#/$defs/identifier" }] },
252
252
  "state_policy": {
253
- "enum": ["carry", "carry_reconciled_metadata", "reset_pending", "removed"],
254
- "$comment": "'removed'はto_task_id==='removed'の時だけ、他の3値はto_task_idがidentifierの時だけ許される(runtime cross-check)。runtime_task_migrationのdisposition投影とも一致していなければならない(validRuntimeTodoProjection)。"
253
+ "enum": ["carry", "carry_reconciled_metadata", "acquire_phase", "reset_pending", "removed"],
254
+ "$comment": "'removed'はto_task_id==='removed'の時だけ、他の4値はto_task_idがidentifierの時だけ許される(runtime cross-check)。runtime_task_migrationのdisposition投影とも一致していなければならない(validRuntimeTodoProjection)。"
255
255
  }
256
256
  }
257
257
  },
@@ -191,8 +191,8 @@
191
191
  "from_task_id": { "$ref": "#/$defs/identifier" },
192
192
  "to_task_id": { "oneOf": [{ "const": "removed" }, { "$ref": "#/$defs/identifier" }] },
193
193
  "state_policy": {
194
- "enum": ["carry", "carry_reconciled_metadata", "reset_pending", "removed"],
195
- "$comment": "'removed'はto_task_id==='removed'の時だけ、他の3値はto_task_idがidentifierの時だけ許される(runtime cross-check)。"
194
+ "enum": ["carry", "carry_reconciled_metadata", "acquire_phase", "reset_pending", "removed"],
195
+ "$comment": "'removed'はto_task_id==='removed'の時だけ、他の4値はto_task_idがidentifierの時だけ許される(runtime cross-check)。"
196
196
  }
197
197
  }
198
198
  },
@@ -209,7 +209,7 @@
209
209
  "properties": {
210
210
  "from_task_id": { "$ref": "#/$defs/identifier" },
211
211
  "to_task_id": { "oneOf": [{ "const": "removed" }, { "$ref": "#/$defs/identifier" }] },
212
- "state_policy": { "enum": ["carry", "carry_reconciled_metadata", "reset_pending", "removed"] }
212
+ "state_policy": { "enum": ["carry", "carry_reconciled_metadata", "acquire_phase", "reset_pending", "removed"] }
213
213
  }
214
214
  },
215
215
  "phaseMigrationEntry": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.39.2",
3
+ "version": "0.40.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -13,6 +13,7 @@ Commands:
13
13
  factory-diagnostics --json Check native factory integration
14
14
  runtime-errors <command> Inspect the local runtime error store
15
15
  bridge <command> Configure the optional network bridge
16
+ hooks <command> Install and run sensor-awareness hooks
16
17
 
17
18
  Options:
18
19
  -h, --help Show help
@@ -143,6 +144,8 @@ Commands:
143
144
 
144
145
  registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPTが
145
146
  両方設定されている時だけ動く。アドレスは送らず、remote側がssh送信元から決める。
147
+ `,
148
+ hooks: `Usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>
146
149
  `,
147
150
  });
148
151
 
@@ -218,6 +221,10 @@ const SUBCOMMAND_USAGE = Object.freeze({
218
221
  'bridge status': 'bridge status --json',
219
222
  'bridge disable': 'bridge disable --json',
220
223
  'bridge register': 'bridge register --json',
224
+ 'hooks install': 'hooks install --host <claude|codex>',
225
+ 'hooks status': 'hooks status --host <claude|codex>',
226
+ 'hooks uninstall': 'hooks uninstall --host <claude|codex>',
227
+ 'hooks emit': 'hooks emit --host <claude|codex>',
221
228
  });
222
229
 
223
230
  function requestedNamespace(argv) {
@@ -0,0 +1,1030 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import {
5
+ access, lstat, link, mkdir, open, readFile, readdir, realpath, rename, stat, unlink,
6
+ } from 'node:fs/promises';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const INFO = 'INFO: このrepoにはLattice sensor index(.lattice/sensor/)があります。コード構造の調査はsensor入口(MCP: lattice_sensor_explore 等/CLI: lattice sensor)を優先できます。';
12
+ const HOSTS = new Set(['claude', 'codex']);
13
+ const MAX_STDIN_BYTES = 64 * 1024;
14
+ const SHOWN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
15
+ const CLAIM_MAX_AGE_MS = 60 * 60 * 1000;
16
+ const RECEIPT_LOCK_MAX_AGE_MS = 30 * 1000;
17
+ const binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../bin/lattice.mjs');
18
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
19
+ const unique = () => `${Date.now()}-${process.pid}-${randomBytes(8).toString('hex')}`;
20
+ const ownUid = () => typeof process.getuid === 'function' ? process.getuid() : null;
21
+
22
+ function writeJson(stdout, value) {
23
+ stdout.write(`${JSON.stringify(value)}\n`);
24
+ }
25
+
26
+ function failure(stdout, code, message, exit = 1, detail) {
27
+ const value = { schema: 'lattice.hooks_error.v1', code, message };
28
+ if (detail !== undefined) value.detail = detail;
29
+ writeJson(stdout, value);
30
+ return exit;
31
+ }
32
+
33
+ function configPath(home, host) {
34
+ return path.join(home, host === 'claude' ? '.claude/settings.json' : '.codex/hooks.json');
35
+ }
36
+
37
+ function stateBase(env) {
38
+ if (path.isAbsolute(env.XDG_STATE_HOME ?? '')) return path.resolve(env.XDG_STATE_HOME);
39
+ return path.join(env.HOME ?? os.homedir(), '.local', 'state');
40
+ }
41
+
42
+ function words(command) {
43
+ const out = [];
44
+ let word = '';
45
+ let quote = null;
46
+ let started = false;
47
+ for (let index = 0; index < command.length; index += 1) {
48
+ const char = command[index];
49
+ if (char === '\\' && quote !== "'") {
50
+ const next = command[index + 1];
51
+ if (next === undefined) return null;
52
+ const escapesNext = quote !== '"' || ['$', '`', '"', '\\', '\n'].includes(next);
53
+ if (!escapesNext) {
54
+ word += '\\';
55
+ started = true;
56
+ continue;
57
+ }
58
+ index += 1;
59
+ if (next !== '\n') {
60
+ word += next;
61
+ started = true;
62
+ }
63
+ continue;
64
+ }
65
+ if ((char === "'" || char === '"') && (!quote || quote === char)) {
66
+ quote = quote ? null : char;
67
+ started = true;
68
+ continue;
69
+ }
70
+ if (/\s/u.test(char) && !quote) {
71
+ if (started) out.push(word);
72
+ word = '';
73
+ started = false;
74
+ } else {
75
+ word += char;
76
+ started = true;
77
+ }
78
+ }
79
+ if (quote) return null;
80
+ if (started) out.push(word);
81
+ return out;
82
+ }
83
+
84
+ function shell(argv) {
85
+ return argv.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ');
86
+ }
87
+
88
+ function sameArgv(left, right) {
89
+ return left.length === right.length && left.every((part, index) => part === right[index]);
90
+ }
91
+
92
+ function commandIs(command, identities) {
93
+ const parsed = words(command);
94
+ return parsed !== null && identities.some((identity) => sameArgv(identity, parsed));
95
+ }
96
+
97
+ function emitCandidate(command, host) {
98
+ const parsed = words(command);
99
+ if (parsed === null) return false;
100
+ return parsed.some((part, index) => part === 'hooks' && parsed[index + 1] === 'emit'
101
+ && parsed[index + 2] === '--host' && parsed[index + 3] === host);
102
+ }
103
+
104
+ async function fsyncDir(directory) {
105
+ const handle = await open(directory, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0));
106
+ try { await handle.sync(); } finally { await handle.close(); }
107
+ }
108
+
109
+ function validateDirectory(info, label) {
110
+ if (info.isSymbolicLink() || !info.isDirectory()) {
111
+ throw Object.assign(new Error(`${label} is not a real directory`), { code: 'STATE_UNSAFE' });
112
+ }
113
+ const uid = ownUid();
114
+ if ((uid !== null && info.uid !== uid) || (info.mode & 0o022) !== 0) {
115
+ throw Object.assign(new Error(`${label} has unsafe ownership or mode`), { code: 'STATE_UNSAFE' });
116
+ }
117
+ }
118
+
119
+ /** Resolve the deepest existing ancestor before creating one component at a time. */
120
+ async function secureStateDirectory(env, components = [], { create = true } = {}) {
121
+ const requestedBase = stateBase(env);
122
+ let ancestor = requestedBase;
123
+ const missing = [];
124
+ while (true) {
125
+ try {
126
+ const info = await lstat(ancestor);
127
+ validateDirectory(info, ancestor);
128
+ break;
129
+ } catch (error) {
130
+ if (error?.code !== 'ENOENT') throw error;
131
+ if (ancestor === path.parse(ancestor).root) throw error;
132
+ missing.unshift(path.basename(ancestor));
133
+ ancestor = path.dirname(ancestor);
134
+ }
135
+ }
136
+ if (!create && missing.length > 0) return null;
137
+ const pinnedAncestor = await realpath(ancestor);
138
+ validateDirectory(await lstat(pinnedAncestor), pinnedAncestor);
139
+ let cursor = pinnedAncestor;
140
+ for (const component of missing) {
141
+ cursor = path.join(cursor, component);
142
+ try {
143
+ await mkdir(cursor, { mode: 0o700 });
144
+ await fsyncDir(path.dirname(cursor));
145
+ } catch (error) {
146
+ if (error?.code !== 'EEXIST') throw error;
147
+ }
148
+ validateDirectory(await lstat(cursor), cursor);
149
+ }
150
+ for (const component of components) {
151
+ cursor = path.join(cursor, component);
152
+ try {
153
+ validateDirectory(await lstat(cursor), cursor);
154
+ continue;
155
+ } catch (error) {
156
+ if (error?.code !== 'ENOENT') throw error;
157
+ if (!create) return null;
158
+ }
159
+ try {
160
+ await mkdir(cursor, { mode: 0o700 });
161
+ await fsyncDir(path.dirname(cursor));
162
+ } catch (error) {
163
+ if (error?.code !== 'EEXIST') throw error;
164
+ }
165
+ validateDirectory(await lstat(cursor), cursor);
166
+ }
167
+ return cursor;
168
+ }
169
+
170
+ function receiptFile(directory, host) {
171
+ return path.join(directory, 'installs', `${host}.json`);
172
+ }
173
+
174
+ function validateRegularOwnerMode(info, mode, code) {
175
+ const uid = ownUid();
176
+ if (!info.isFile() || (uid !== null && info.uid !== uid) || (info.mode & 0o777) !== mode) {
177
+ throw Object.assign(new Error('unsafe owned file'), { code });
178
+ }
179
+ }
180
+
181
+ async function readOwnedFile(target, { absent = null, code = 'UNSAFE_FILE' } = {}) {
182
+ let handle;
183
+ try {
184
+ handle = await open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
185
+ } catch (error) {
186
+ if (error?.code === 'ENOENT') return absent;
187
+ throw Object.assign(error, { code });
188
+ }
189
+ try {
190
+ validateRegularOwnerMode(await handle.stat(), 0o600, code);
191
+ return await handle.readFile();
192
+ } finally {
193
+ await handle.close();
194
+ }
195
+ }
196
+
197
+ function parseReceipt(bytes) {
198
+ if (bytes === null) return { schema: 'lattice.hooks_install_receipt.v1', entries: [] };
199
+ let value;
200
+ try { value = JSON.parse(bytes); } catch {
201
+ throw Object.assign(new Error('receipt is not JSON'), { code: 'INSTALL_RECEIPT_UNSAFE' });
202
+ }
203
+ if (value?.schema !== 'lattice.hooks_install_receipt.v1' || !Array.isArray(value.entries)
204
+ || value.entries.some((entry) => !Array.isArray(entry?.argv)
205
+ || !entry.argv.every((part) => typeof part === 'string')
206
+ || !['pending', 'committed'].includes(entry.status))) {
207
+ throw Object.assign(new Error('receipt shape is invalid'), { code: 'INSTALL_RECEIPT_UNSAFE' });
208
+ }
209
+ return value;
210
+ }
211
+
212
+ async function receiptLocation(env, host, create) {
213
+ const hooksDirectory = await secureStateDirectory(env, ['lattice', 'hooks'], { create });
214
+ if (hooksDirectory === null) return null;
215
+ const installs = path.join(hooksDirectory, 'installs');
216
+ if (create) {
217
+ try { await mkdir(installs, { mode: 0o700 }); await fsyncDir(hooksDirectory); } catch (error) {
218
+ if (error?.code !== 'EEXIST') throw error;
219
+ }
220
+ validateDirectory(await lstat(installs), installs);
221
+ } else {
222
+ try { validateDirectory(await lstat(installs), installs); } catch (error) {
223
+ if (error?.code === 'ENOENT') return null;
224
+ throw error;
225
+ }
226
+ }
227
+ return receiptFile(hooksDirectory, host);
228
+ }
229
+
230
+ async function readReceipt(env, host) {
231
+ const target = await receiptLocation(env, host, false);
232
+ if (target === null) return { target: null, value: parseReceipt(null) };
233
+ return {
234
+ target,
235
+ value: parseReceipt(await readOwnedFile(target, {
236
+ absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
237
+ })),
238
+ };
239
+ }
240
+
241
+ async function writeReceiptUnlocked(target, value) {
242
+ const directory = path.dirname(target);
243
+ const existing = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
244
+ if (existing !== null) parseReceipt(existing);
245
+ const tmp = `${target}.tmp-lattice-hooks-${unique()}`;
246
+ let handle;
247
+ try {
248
+ handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
249
+ | fsConstants.O_NOFOLLOW, 0o600);
250
+ await handle.chmod(0o600);
251
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
252
+ await handle.sync();
253
+ await handle?.close();
254
+ handle = null;
255
+ const beforeRename = await readOwnedFile(target, {
256
+ absent: null, code: 'INSTALL_RECEIPT_UNSAFE',
257
+ });
258
+ if ((existing === null) !== (beforeRename === null)
259
+ || (existing !== null && !existing.equals(beforeRename))) {
260
+ throw Object.assign(new Error('receipt changed concurrently'), { code: 'INSTALL_RECEIPT_BUSY' });
261
+ }
262
+ await rename(tmp, target);
263
+ await fsyncDir(directory);
264
+ parseReceipt(await readOwnedFile(target, { code: 'INSTALL_RECEIPT_UNSAFE' }));
265
+ } finally {
266
+ await handle?.close().catch(() => {});
267
+ await removeArtifact(tmp).catch(() => {});
268
+ }
269
+ }
270
+
271
+ async function withReceiptLock(env, host, operation) {
272
+ const target = await receiptLocation(env, host, true);
273
+ const lockPath = `${target}.lock`;
274
+ let lock;
275
+ let acquired = false;
276
+ for (let attempt = 0; attempt < 60; attempt += 1) {
277
+ let created = false;
278
+ try {
279
+ lock = await open(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
280
+ | fsConstants.O_NOFOLLOW, 0o600);
281
+ created = true;
282
+ await lock.chmod(0o600);
283
+ await lock.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
284
+ await lock.sync();
285
+ await lock.close();
286
+ lock = null;
287
+ await fsyncDir(path.dirname(lockPath));
288
+ acquired = true;
289
+ break;
290
+ } catch (error) {
291
+ await lock?.close().catch(() => {});
292
+ lock = null;
293
+ if (created) await removeArtifact(lockPath).catch(() => {});
294
+ if (error?.code !== 'EEXIST') throw error;
295
+ try {
296
+ if (Date.now() - (await lstat(lockPath)).mtimeMs > RECEIPT_LOCK_MAX_AGE_MS) {
297
+ await unlink(lockPath);
298
+ await fsyncDir(path.dirname(lockPath));
299
+ continue;
300
+ }
301
+ } catch (statError) {
302
+ if (statError?.code === 'ENOENT') continue;
303
+ throw statError;
304
+ }
305
+ await new Promise((resolve) => setTimeout(resolve, 10));
306
+ }
307
+ }
308
+ if (!acquired) {
309
+ throw Object.assign(new Error('receipt lock unavailable'), { code: 'INSTALL_RECEIPT_BUSY' });
310
+ }
311
+ try {
312
+ const bytes = await readOwnedFile(target, { absent: null, code: 'INSTALL_RECEIPT_UNSAFE' });
313
+ return await operation(parseReceipt(bytes), target);
314
+ } finally {
315
+ await unlink(lockPath).catch(() => {});
316
+ await fsyncDir(path.dirname(lockPath)).catch(() => {});
317
+ }
318
+ }
319
+
320
+ function allHandlers(value) {
321
+ const list = value?.hooks?.UserPromptSubmit;
322
+ if (!Array.isArray(list)) return [];
323
+ return list.flatMap((wrapper) => Array.isArray(wrapper?.hooks) ? wrapper.hooks : []);
324
+ }
325
+
326
+ function configContains(value, argv) {
327
+ return allHandlers(value).some((item) => item?.type === 'command'
328
+ && typeof item.command === 'string' && commandIs(item.command, [argv]));
329
+ }
330
+
331
+ async function recoverReceipt(env, host, configTarget, testHooks) {
332
+ const current = await readReceipt(env, host);
333
+ if (current.target === null || !current.value.entries.some((entry) => entry.status === 'pending')) {
334
+ return current.value;
335
+ }
336
+ return withReceiptLock(env, host, async (receipt, target) => {
337
+ await testHooks.afterReceiptLock?.({ configTarget });
338
+ const config = await readConfig(configTarget);
339
+ const entries = receipt.entries.flatMap((entry) => {
340
+ if (entry.status !== 'pending') return [entry];
341
+ return configContains(config.value, entry.argv) ? [{ ...entry, status: 'committed' }] : [];
342
+ });
343
+ const recovered = { ...receipt, entries };
344
+ await writeReceiptUnlocked(target, recovered);
345
+ return recovered;
346
+ });
347
+ }
348
+
349
+ async function appendPending(env, host, argv) {
350
+ const operationId = unique();
351
+ await withReceiptLock(env, host, async (receipt, target) => {
352
+ const entries = [...receipt.entries];
353
+ entries.push({ argv, status: 'pending', operation_id: operationId, recorded_at: new Date().toISOString() });
354
+ await writeReceiptUnlocked(target, { ...receipt, entries });
355
+ });
356
+ return operationId;
357
+ }
358
+
359
+ async function commitPending(env, host, operationId, argv) {
360
+ await withReceiptLock(env, host, async (receipt, target) => {
361
+ let found = false;
362
+ const entries = receipt.entries.map((entry) => {
363
+ if (entry.operation_id !== operationId) return entry;
364
+ found = true;
365
+ return { ...entry, status: 'committed' };
366
+ });
367
+ if (!found) entries.push({
368
+ argv, status: 'committed', operation_id: operationId, recorded_at: new Date().toISOString(),
369
+ });
370
+ await writeReceiptUnlocked(target, { ...receipt, entries });
371
+ });
372
+ }
373
+
374
+ async function readConfig(target) {
375
+ let info;
376
+ try { info = await lstat(target); } catch (error) {
377
+ if (error?.code === 'ENOENT') {
378
+ return { existed: false, mode: 0o600, bytes: Buffer.alloc(0), value: {} };
379
+ }
380
+ throw error;
381
+ }
382
+ if (info.isSymbolicLink()) throw Object.assign(new Error('config is symlink'), { code: 'SYMLINK' });
383
+ if (!info.isFile()) throw Object.assign(new Error('config is not regular'), { code: 'CONFIG_INVALID' });
384
+ const bytes = await readFile(target);
385
+ let value;
386
+ try { value = JSON.parse(bytes); } catch {
387
+ throw Object.assign(new Error('config is not JSON'), { code: 'CONFIG_INVALID' });
388
+ }
389
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
390
+ throw Object.assign(new Error('config root is not an object'), { code: 'CONFIG_INVALID' });
391
+ }
392
+ if (value.hooks !== undefined && (value.hooks === null || typeof value.hooks !== 'object'
393
+ || Array.isArray(value.hooks))) {
394
+ throw Object.assign(new Error('hooks is not an object'), { code: 'CONFIG_INVALID' });
395
+ }
396
+ if (value.hooks?.UserPromptSubmit !== undefined && !Array.isArray(value.hooks.UserPromptSubmit)) {
397
+ throw Object.assign(new Error('UserPromptSubmit is not an array'), { code: 'CONFIG_INVALID' });
398
+ }
399
+ return { existed: true, mode: info.mode & 0o777, bytes, value };
400
+ }
401
+
402
+ function hooksList(value) {
403
+ if (value.hooks === undefined) value.hooks = {};
404
+ if (value.hooks.UserPromptSubmit === undefined) value.hooks.UserPromptSubmit = [];
405
+ return value.hooks.UserPromptSubmit;
406
+ }
407
+
408
+ function stripIdentity(value, identities) {
409
+ let removed = 0;
410
+ const list = hooksList(value);
411
+ value.hooks.UserPromptSubmit = list.flatMap((wrapper) => {
412
+ if (!wrapper || !Array.isArray(wrapper.hooks)) return [wrapper];
413
+ const handlers = wrapper.hooks.filter((item) => {
414
+ const owned = item?.type === 'command' && typeof item.command === 'string'
415
+ && commandIs(item.command, identities);
416
+ if (owned) removed += 1;
417
+ return !owned;
418
+ });
419
+ return handlers.length > 0 ? [{ ...wrapper, hooks: handlers }] : [];
420
+ });
421
+ return removed;
422
+ }
423
+
424
+ function hostHandler(host, command) {
425
+ return host === 'claude'
426
+ ? { type: 'command', command, timeout: 5 }
427
+ : { type: 'command', command, timeout: 5, async: false, statusMessage: null };
428
+ }
429
+
430
+ async function resolveCanonical(host, source) {
431
+ const sourcePaths = [source.execPath, source.binPath];
432
+ if (sourcePaths.some((entry) => typeof entry !== 'string' || !path.isAbsolute(entry)
433
+ || /[\0\r\n]/u.test(entry))) {
434
+ throw Object.assign(new Error('install source is not absolute'), { code: 'INSTALL_SOURCE_UNRESOLVED' });
435
+ }
436
+ try {
437
+ await access(source.execPath, fsConstants.X_OK);
438
+ const script = await realpath(source.binPath);
439
+ if (/[\0\r\n]/u.test(script)) throw new Error('resolved install source has unsafe characters');
440
+ await access(script, fsConstants.R_OK | fsConstants.X_OK);
441
+ return [source.execPath, script, 'hooks', 'emit', '--host', host];
442
+ } catch (error) {
443
+ throw Object.assign(error, { code: 'INSTALL_SOURCE_UNRESOLVED' });
444
+ }
445
+ }
446
+
447
+ async function createBackup(target, prestate) {
448
+ if (!prestate.existed) return null;
449
+ const ref = `${target}.bak-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
450
+ let handle;
451
+ try {
452
+ handle = await open(ref, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
453
+ | fsConstants.O_NOFOLLOW, 0o600);
454
+ await handle.chmod(0o600);
455
+ await handle.writeFile(prestate.bytes);
456
+ await handle.sync();
457
+ await handle.close();
458
+ handle = null;
459
+ await fsyncDir(path.dirname(target));
460
+ return ref;
461
+ } catch (error) {
462
+ await handle?.close().catch(() => {});
463
+ await removeArtifact(ref).catch(() => {});
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ async function removeArtifact(target) {
469
+ if (target === null) return;
470
+ try { await unlink(target); await fsyncDir(path.dirname(target)); } catch (error) {
471
+ if (error?.code !== 'ENOENT') throw error;
472
+ }
473
+ }
474
+
475
+ async function pruneGenerations(target) {
476
+ const directory = path.dirname(target);
477
+ const basename = path.basename(target);
478
+ for (const marker of ['bak-lattice-hooks-', 'pre-lattice-hooks-']) {
479
+ const prefix = `${basename}.${marker}`;
480
+ const entries = [];
481
+ for (const name of await readdir(directory)) {
482
+ if (!name.startsWith(prefix)) continue;
483
+ const full = path.join(directory, name);
484
+ try { entries.push({ full, mtimeMs: (await lstat(full)).mtimeMs }); } catch (error) {
485
+ if (error?.code !== 'ENOENT') throw error;
486
+ }
487
+ }
488
+ entries.sort((left, right) => right.mtimeMs - left.mtimeMs || right.full.localeCompare(left.full));
489
+ for (const entry of entries.slice(5)) await removeArtifact(entry.full);
490
+ }
491
+ }
492
+
493
+ async function writeTmp(target, bytes, mode, tag = 'tmp') {
494
+ const tmp = `${target}.${tag}-lattice-hooks-${unique()}`;
495
+ let handle;
496
+ try {
497
+ handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
498
+ | fsConstants.O_NOFOLLOW, mode);
499
+ await handle.chmod(mode);
500
+ await handle.writeFile(bytes);
501
+ await handle.sync();
502
+ await handle.close();
503
+ handle = null;
504
+ return tmp;
505
+ } catch (error) {
506
+ await handle?.close().catch(() => {});
507
+ await removeArtifact(tmp).catch(() => {});
508
+ throw error;
509
+ }
510
+ }
511
+
512
+ async function restoreExisting(target, displaced, mode, testHooks) {
513
+ await testHooks.beforeRestore?.({ target, displaced });
514
+ const bytes = await readFile(displaced);
515
+ const tmp = await writeTmp(target, bytes, mode, 'restore');
516
+ try {
517
+ await rename(tmp, target);
518
+ await fsyncDir(path.dirname(target));
519
+ if (!(await readFile(target)).equals(bytes)) throw new Error('restore read-back mismatch');
520
+ } finally {
521
+ await unlink(tmp).catch(() => {});
522
+ }
523
+ }
524
+
525
+ async function rollbackAbsent(target, tmp) {
526
+ const targetInfo = await lstat(target);
527
+ const tmpInfo = await lstat(tmp);
528
+ if (targetInfo.dev !== tmpInfo.dev || targetInfo.ino !== tmpInfo.ino) {
529
+ throw new Error('created target was replaced before rollback');
530
+ }
531
+ await unlink(target);
532
+ await fsyncDir(path.dirname(target));
533
+ try { await lstat(target); throw new Error('absent rollback read-back failed'); } catch (error) {
534
+ if (error?.code !== 'ENOENT') throw error;
535
+ }
536
+ }
537
+
538
+ async function commitConfig(target, prestate, value, testHooks) {
539
+ const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
540
+ JSON.parse(serialized);
541
+ const tmp = await writeTmp(target, serialized, prestate.existed ? prestate.mode : 0o600);
542
+ let displaced = null;
543
+ let committed = false;
544
+ let complete = false;
545
+ try {
546
+ if (prestate.existed) {
547
+ await testHooks.beforePreimageVerify?.({ target, prestate });
548
+ const current = await readConfig(target);
549
+ if (!current.existed || !current.bytes.equals(prestate.bytes)) {
550
+ throw Object.assign(new Error('preimage changed'), { code: 'PREIMAGE_CHANGED' });
551
+ }
552
+ displaced = `${target}.pre-lattice-hooks-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${unique()}`;
553
+ await link(target, displaced);
554
+ await fsyncDir(path.dirname(target));
555
+ await testHooks.afterDisplacedLink?.({ target, displaced });
556
+ const before = await lstat(target);
557
+ const saved = await lstat(displaced);
558
+ if (!before.isFile() || before.dev !== saved.dev || before.ino !== saved.ino) {
559
+ throw Object.assign(new Error('target inode changed'), { code: 'PREIMAGE_CHANGED' });
560
+ }
561
+ await rename(tmp, target);
562
+ committed = true;
563
+ await fsyncDir(path.dirname(target));
564
+ } else {
565
+ try { await link(tmp, target); } catch (error) {
566
+ if (error?.code === 'EEXIST') {
567
+ throw Object.assign(error, { code: 'PREIMAGE_CHANGED' });
568
+ }
569
+ throw error;
570
+ }
571
+ committed = true;
572
+ await fsyncDir(path.dirname(target));
573
+ }
574
+ await testHooks.beforeConfigReadBack?.({ target, serialized, displaced });
575
+ if (!(await readFile(target)).equals(serialized)) throw new Error('config read-back mismatch');
576
+ complete = true;
577
+ return displaced;
578
+ } catch (error) {
579
+ if (committed) {
580
+ try {
581
+ if (prestate.existed) await restoreExisting(target, displaced, prestate.mode, testHooks);
582
+ else await rollbackAbsent(target, tmp);
583
+ } catch (restoreError) {
584
+ throw Object.assign(new Error(`restore failed: ${restoreError.message}`), {
585
+ code: 'RESTORE_FAILED', cause: error, commitOccurred: true, displacedPath: displaced,
586
+ });
587
+ }
588
+ Object.assign(error, { commitOccurred: true, displacedPath: displaced });
589
+ }
590
+ throw error;
591
+ } finally {
592
+ await removeArtifact(tmp).catch(() => {});
593
+ if (!complete && !committed) await removeArtifact(displaced).catch(() => {});
594
+ }
595
+ }
596
+
597
+ function committedIdentities(receipt) {
598
+ return receipt.entries.filter((entry) => entry.status === 'committed').map((entry) => entry.argv);
599
+ }
600
+
601
+ function exactHandler(item, expected) {
602
+ return JSON.stringify(item) === JSON.stringify(expected);
603
+ }
604
+
605
+ async function hostDirectory(home, host) {
606
+ const directory = path.dirname(configPath(home, host));
607
+ try {
608
+ const info = await lstat(directory);
609
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('not a directory');
610
+ return directory;
611
+ } catch {
612
+ return null;
613
+ }
614
+ }
615
+
616
+ async function mutate(host, env, stdout, uninstall, source, testHooks) {
617
+ const home = env.HOME ?? os.homedir();
618
+ if (await hostDirectory(home, host) === null) {
619
+ return failure(stdout, 'HOST_NOT_PRESENT', 'host home directory is not present');
620
+ }
621
+ let current;
622
+ try { current = await resolveCanonical(host, source); } catch {
623
+ return failure(stdout, 'INSTALL_SOURCE_UNRESOLVED', 'install source cannot be resolved');
624
+ }
625
+ const target = configPath(home, host);
626
+ for (let attempt = 0; attempt < 2; attempt += 1) {
627
+ let prestate;
628
+ try { prestate = await readConfig(target); } catch (error) {
629
+ return failure(stdout, error?.code === 'SYMLINK' ? 'CONFIG_SYMLINK_UNSUPPORTED'
630
+ : 'CONFIG_UNREADABLE', 'configuration cannot be read');
631
+ }
632
+ let receipt;
633
+ try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
634
+ return failure(stdout, 'INSTALL_RECEIPT_UNSAFE', 'install receipt cannot be safely read');
635
+ }
636
+ const identities = [current, ...committedIdentities(receipt)];
637
+ const next = structuredClone(prestate.value);
638
+ const removed = stripIdentity(next, identities);
639
+ if (!uninstall) hooksList(next).push({ hooks: [hostHandler(host, shell(current))] });
640
+
641
+ const currentHandlers = allHandlers(prestate.value).filter((item) => item?.type === 'command'
642
+ && typeof item.command === 'string' && commandIs(item.command, identities));
643
+ const alreadyWired = !uninstall && currentHandlers.length === 1
644
+ && commandIs(currentHandlers[0].command, [current])
645
+ && exactHandler(currentHandlers[0], hostHandler(host, shell(current)));
646
+ if ((uninstall && removed === 0) || alreadyWired) {
647
+ writeJson(stdout, uninstall
648
+ ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: 0 }
649
+ : { schema: 'lattice.hooks_install_result.v1', host, state: 'already_wired' });
650
+ return 0;
651
+ }
652
+
653
+ let pendingId = null;
654
+ let backup = null;
655
+ let configCommitted = false;
656
+ try {
657
+ if (!uninstall) pendingId = await appendPending(env, host, current);
658
+ backup = await createBackup(target, prestate);
659
+ await commitConfig(target, prestate, next, testHooks);
660
+ configCommitted = true;
661
+ if (!uninstall) await commitPending(env, host, pendingId, current);
662
+ let warning;
663
+ try {
664
+ await testHooks.beforePrune?.({ target });
665
+ await pruneGenerations(target);
666
+ } catch (error) {
667
+ warning = { code: 'GENERATION_PRUNE_FAILED', message: error?.message ?? 'generation prune failed' };
668
+ }
669
+ const result = uninstall
670
+ ? { schema: 'lattice.hooks_uninstall_result.v1', host, removed_count: removed }
671
+ : { schema: 'lattice.hooks_install_result.v1', host, state: 'wired' };
672
+ if (warning !== undefined) result.warning = warning;
673
+ writeJson(stdout, result);
674
+ return 0;
675
+ } catch (error) {
676
+ if (!configCommitted && !error?.commitOccurred) await removeArtifact(backup).catch(() => {});
677
+ if (!configCommitted && error?.code === 'PREIMAGE_CHANGED' && attempt === 0) continue;
678
+ const code = error?.code === 'RESTORE_FAILED' ? 'RESTORE_FAILED'
679
+ : ['INSTALL_RECEIPT_UNSAFE', 'INSTALL_RECEIPT_BUSY'].includes(error?.code)
680
+ ? 'INSTALL_RECEIPT_UNSAFE'
681
+ : 'CONFIG_WRITE_FAILED';
682
+ const detail = code === 'RESTORE_FAILED' ? {
683
+ backup_path: backup,
684
+ displaced_path: error.displacedPath,
685
+ } : undefined;
686
+ return failure(stdout, code, 'configuration cannot be safely written', 1, detail);
687
+ }
688
+ }
689
+ return failure(stdout, 'CONFIG_WRITE_FAILED', 'configuration changed concurrently');
690
+ }
691
+
692
+ function statusResult(host, target, canonicalCommand, state, matches, executableOk,
693
+ foreignCandidateCount) {
694
+ return {
695
+ schema: 'lattice.hooks_status_result.v1',
696
+ host,
697
+ config_path: target,
698
+ state,
699
+ canonical_command: canonicalCommand,
700
+ matched_handler_count: matches,
701
+ foreign_candidate_count: foreignCandidateCount,
702
+ executable_ok: executableOk,
703
+ next_action: state === 'wired' ? null : `lattice hooks install --host ${host}`,
704
+ };
705
+ }
706
+
707
+ async function status(host, env, stdout, source, platform, testHooks) {
708
+ const home = env.HOME ?? os.homedir();
709
+ const target = configPath(home, host);
710
+ let argv;
711
+ try { argv = await resolveCanonical(host, source); } catch {
712
+ writeJson(stdout, statusResult(host, target, null, 'unreadable', 0, false, 0));
713
+ return 1;
714
+ }
715
+ if (platform === 'win32' || await hostDirectory(home, host) === null) {
716
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
717
+ return 1;
718
+ }
719
+ let config;
720
+ try { config = await readConfig(target); } catch {
721
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
722
+ return 1;
723
+ }
724
+ let receipt;
725
+ try { receipt = await recoverReceipt(env, host, target, testHooks); } catch {
726
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
727
+ return 1;
728
+ }
729
+ try { config = await readConfig(target); } catch {
730
+ writeJson(stdout, statusResult(host, target, shell(argv), 'unreadable', 0, false, 0));
731
+ return 1;
732
+ }
733
+ const identities = [argv, ...committedIdentities(receipt)];
734
+ let matches = 0;
735
+ let canonicalMatches = 0;
736
+ let foreign = 0;
737
+ let canonicalShape = false;
738
+ for (const item of allHandlers(config.value)) {
739
+ if (item?.type !== 'command' || typeof item.command !== 'string') continue;
740
+ if (commandIs(item.command, identities)) {
741
+ matches += 1;
742
+ if (commandIs(item.command, [argv])) {
743
+ canonicalMatches += 1;
744
+ canonicalShape = exactHandler(item, hostHandler(host, shell(argv)));
745
+ }
746
+ } else if (emitCandidate(item.command, host)) foreign += 1;
747
+ }
748
+ const executableOk = await Promise.all(argv.slice(0, 2).map(async (entry) => {
749
+ try { await access(entry, fsConstants.X_OK); return (await stat(entry)).isFile(); } catch { return false; }
750
+ })).then((values) => values.every(Boolean));
751
+ let state = 'drift';
752
+ if (matches === 0 && foreign === 0) state = 'not_wired';
753
+ else if (matches === 1 && canonicalMatches === 1 && canonicalShape && executableOk && foreign === 0) {
754
+ state = 'wired';
755
+ }
756
+ writeJson(stdout, statusResult(host, target, shell(argv), state, matches, executableOk, foreign));
757
+ return 0;
758
+ }
759
+
760
+ async function appendError(state, message) {
761
+ const target = path.join(state, 'errors.log');
762
+ try {
763
+ const created = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT
764
+ | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
765
+ try { await created.chmod(0o600); await created.sync(); } finally { await created.close(); }
766
+ await fsyncDir(state);
767
+ } catch (error) {
768
+ if (error?.code !== 'EEXIST') throw error;
769
+ }
770
+ const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
771
+ | fsConstants.O_NOFOLLOW, 0o600);
772
+ try {
773
+ validateRegularOwnerMode(await handle.stat(), 0o600, 'ERROR_LOG_UNSAFE');
774
+ await handle.writeFile(`${new Date().toISOString()} ${message}\n`);
775
+ await handle.sync();
776
+ } finally { await handle.close(); }
777
+ }
778
+
779
+ async function recordOrDiagnose(state, stdout, message, diagnostic) {
780
+ try { await appendError(state, message); } catch { stdout.write(`Lattice hooks: ${diagnostic}\n`); }
781
+ }
782
+
783
+ async function diagnose(state, stdout, message, diagnostic) {
784
+ try { await appendError(state, message); } catch {}
785
+ stdout.write(`Lattice hooks: ${diagnostic}\n`);
786
+ }
787
+
788
+ async function readHookInput(stdin) {
789
+ const chunks = [];
790
+ let length = 0;
791
+ let tooLarge = false;
792
+ for await (const chunk of stdin) {
793
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
794
+ length += bytes.length;
795
+ if (length <= MAX_STDIN_BYTES) chunks.push(bytes);
796
+ else tooLarge = true;
797
+ }
798
+ if (tooLarge) throw new Error('hook stdin exceeds 64KiB');
799
+ let event;
800
+ try { event = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
801
+ throw new Error('hook stdin is not strict JSON');
802
+ }
803
+ if (typeof event?.session_id !== 'string' || event.session_id.length === 0
804
+ || typeof event.cwd !== 'string' || !path.isAbsolute(event.cwd)) {
805
+ throw new Error('hook stdin requires session_id and absolute cwd');
806
+ }
807
+ try {
808
+ const cwd = await realpath(event.cwd);
809
+ if (!(await lstat(cwd)).isDirectory()) throw new Error('cwd is not a directory');
810
+ return { ...event, cwd };
811
+ } catch {
812
+ throw new Error('hook cwd is unavailable');
813
+ }
814
+ }
815
+
816
+ async function gitRoot(cwd, spawnImpl, timeoutMs) {
817
+ return new Promise((resolve) => {
818
+ let settled = false;
819
+ let out = '';
820
+ let child;
821
+ const finish = (result) => {
822
+ if (settled) return;
823
+ settled = true;
824
+ clearTimeout(timer);
825
+ resolve(result);
826
+ };
827
+ try {
828
+ child = spawnImpl('git', ['--no-optional-locks', '-C', cwd, 'rev-parse', '--show-toplevel'], {
829
+ shell: false, stdio: ['ignore', 'pipe', 'ignore'],
830
+ });
831
+ } catch (error) {
832
+ resolve({ kind: 'error', error });
833
+ return;
834
+ }
835
+ const timer = setTimeout(() => {
836
+ child.kill('SIGKILL');
837
+ finish({ kind: 'error', error: new Error('git root lookup timed out') });
838
+ }, timeoutMs);
839
+ child.once('error', (error) => finish({ kind: 'error', error }));
840
+ child.stdout.on('error', (error) => finish({ kind: 'error', error }));
841
+ child.stdout.on('data', (data) => {
842
+ out += data;
843
+ if (Buffer.byteLength(out) > MAX_STDIN_BYTES) {
844
+ child.kill('SIGKILL');
845
+ finish({ kind: 'error', error: new Error('git output too large') });
846
+ }
847
+ });
848
+ child.once('close', (code, signal) => {
849
+ if (code !== 0) {
850
+ finish(signal === null ? { kind: 'not_git' }
851
+ : { kind: 'error', error: new Error(`git terminated by ${signal}`) });
852
+ return;
853
+ }
854
+ const root = out.trim();
855
+ finish(path.isAbsolute(root) ? { kind: 'root', root }
856
+ : { kind: 'error', error: new Error('git returned a non-absolute root') });
857
+ });
858
+ });
859
+ }
860
+
861
+ const ownNotificationPattern = /^[a-f0-9]{64}\.[a-f0-9]{64}\.(?:shown|claim)$/u;
862
+
863
+ async function gcNotifications(state) {
864
+ const now = Date.now();
865
+ for (const name of await readdir(state)) {
866
+ if (!ownNotificationPattern.test(name)) continue;
867
+ const target = path.join(state, name);
868
+ let info;
869
+ try { info = await lstat(target); } catch (error) {
870
+ if (error?.code === 'ENOENT') continue;
871
+ throw error;
872
+ }
873
+ const maximum = name.endsWith('.claim') ? CLAIM_MAX_AGE_MS : SHOWN_MAX_AGE_MS;
874
+ if (now - info.mtimeMs > maximum) await removeArtifact(target);
875
+ }
876
+ }
877
+
878
+ async function freshShown(shown) {
879
+ try { return Date.now() - (await lstat(shown)).mtimeMs <= SHOWN_MAX_AGE_MS; } catch (error) {
880
+ if (error?.code === 'ENOENT') return false;
881
+ throw error;
882
+ }
883
+ }
884
+
885
+ async function acquireClaim(claim) {
886
+ let handle;
887
+ let created = false;
888
+ try {
889
+ handle = await open(claim, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
890
+ | fsConstants.O_NOFOLLOW, 0o600);
891
+ created = true;
892
+ await handle.chmod(0o600);
893
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`);
894
+ await handle.sync();
895
+ await handle.close();
896
+ handle = null;
897
+ await fsyncDir(path.dirname(claim));
898
+ return true;
899
+ } catch (error) {
900
+ await handle?.close().catch(() => {});
901
+ if (created) await removeArtifact(claim).catch(() => {});
902
+ if (error?.code === 'EEXIST') return false;
903
+ throw error;
904
+ }
905
+ }
906
+
907
+ function outputLine(host) {
908
+ return host === 'claude' ? `${INFO}\n` : `${JSON.stringify({
909
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: INFO },
910
+ })}\n`;
911
+ }
912
+
913
+ async function writeOutput(stdout, line) {
914
+ await new Promise((resolve, reject) => {
915
+ try { stdout.write(line, (error) => error ? reject(error) : resolve()); } catch (error) { reject(error); }
916
+ });
917
+ }
918
+
919
+ async function emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks) {
920
+ if (env.LATTICE_HOOKS === 'off') return 0;
921
+ let state;
922
+ try { state = await secureStateDirectory(env, ['lattice', 'hooks']); } catch {
923
+ stdout.write('Lattice hooks: state directory unavailable\n');
924
+ return 0;
925
+ }
926
+ let event;
927
+ try { event = await readHookInput(stdin); } catch (error) {
928
+ await recordOrDiagnose(state, stdout, error.message, 'cannot record invalid stdin');
929
+ return 0;
930
+ }
931
+ const git = await gitRoot(event.cwd, spawnImpl, gitTimeoutMs);
932
+ if (git.kind === 'not_git') return 0;
933
+ if (git.kind === 'error') {
934
+ await diagnose(state, stdout, git.error.message, 'git root lookup failed');
935
+ return 0;
936
+ }
937
+ const sensor = path.join(git.root, '.lattice', 'sensor');
938
+ try {
939
+ if (!(await lstat(sensor)).isDirectory()) return 0;
940
+ } catch (error) {
941
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return 0;
942
+ await diagnose(state, stdout, `sensor lookup failed: ${error.message}`, 'sensor index unavailable');
943
+ return 0;
944
+ }
945
+ try { await gcNotifications(state); } catch (error) {
946
+ await diagnose(state, stdout, `notification gc failed: ${error.message}`, 'notification state unavailable');
947
+ return 0;
948
+ }
949
+ const key = `${hash(event.session_id)}.${hash(git.root)}`;
950
+ const shown = path.join(state, `${key}.shown`);
951
+ const claim = path.join(state, `${key}.claim`);
952
+ try {
953
+ if (await freshShown(shown)) return 0;
954
+ } catch (error) {
955
+ await diagnose(state, stdout, `shown precheck failed: ${error.message}`, 'notification state unavailable');
956
+ return 0;
957
+ }
958
+ try {
959
+ if (!await acquireClaim(claim)) return 0;
960
+ } catch (error) {
961
+ await diagnose(state, stdout, `claim failed: ${error.message}`, 'notification claim unavailable');
962
+ return 0;
963
+ }
964
+ try {
965
+ if (await freshShown(shown)) {
966
+ await removeArtifact(claim);
967
+ return 0;
968
+ }
969
+ } catch (error) {
970
+ await removeArtifact(claim).catch(() => {});
971
+ await diagnose(state, stdout, `shown recheck failed: ${error.message}`, 'notification state unavailable');
972
+ return 0;
973
+ }
974
+ try {
975
+ await writeOutput(stdout, outputLine(host));
976
+ } catch (error) {
977
+ await removeArtifact(claim).catch(() => {});
978
+ await recordOrDiagnose(state, stdout, `notification output failed: ${error.message}`,
979
+ 'notification output failed');
980
+ return 0;
981
+ }
982
+ try {
983
+ await testHooks.beforeShownRename?.({ claim, shown });
984
+ await rename(claim, shown);
985
+ await fsyncDir(state);
986
+ } catch (error) {
987
+ await recordOrDiagnose(state, stdout, `claim promotion failed: ${error.message}`,
988
+ 'notification record failed');
989
+ try {
990
+ const handle = await open(shown, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
991
+ | fsConstants.O_NOFOLLOW, 0o600);
992
+ await handle.chmod(0o600);
993
+ await handle.sync();
994
+ await handle.close();
995
+ await fsyncDir(state);
996
+ } catch (fallbackError) {
997
+ await recordOrDiagnose(state, stdout, `shown fallback failed: ${fallbackError.message}`,
998
+ 'notification record failed');
999
+ }
1000
+ await removeArtifact(claim).catch(() => {});
1001
+ }
1002
+ return 0;
1003
+ }
1004
+
1005
+ export async function runHooksCli({
1006
+ argv,
1007
+ stdout,
1008
+ stdin = process.stdin,
1009
+ env = process.env,
1010
+ platform = process.platform,
1011
+ source = { execPath: process.execPath, binPath },
1012
+ spawnImpl = spawn,
1013
+ gitTimeoutMs = 2000,
1014
+ testHooks = {},
1015
+ }) {
1016
+ if (argv.length !== 3 || !['install', 'status', 'uninstall', 'emit'].includes(argv[0])
1017
+ || argv[1] !== '--host' || !HOSTS.has(argv[2])) {
1018
+ return failure(stdout, 'USAGE',
1019
+ 'usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>', 2);
1020
+ }
1021
+ const [command, , host] = argv;
1022
+ if (platform === 'win32') {
1023
+ if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1024
+ return failure(stdout, 'HOST_PLATFORM_UNSUPPORTED', 'native Windows hooks are unsupported');
1025
+ }
1026
+ if (command === 'install') return mutate(host, env, stdout, false, source, testHooks);
1027
+ if (command === 'uninstall') return mutate(host, env, stdout, true, source, testHooks);
1028
+ if (command === 'status') return status(host, env, stdout, source, platform, testHooks);
1029
+ return emit(host, env, stdin, stdout, spawnImpl, gitTimeoutMs, testHooks);
1030
+ }
@@ -3313,6 +3313,17 @@ export async function runManagedSupervisorDaemon({
3313
3313
  return buildControlResponse(controlRequest, 'unknown', result,
3314
3314
  journal.at(-1)?.event_digest ?? null);
3315
3315
  }
3316
+ // 初回daemonにはrestart candidate pointerが無い。完了後に元socketの応答だけを失った
3317
+ // 同一requestをpointer recoveryへ流すと、既存activationをもう一度executeしてRUN_BUSYになる。
3318
+ // ledgerのexact request digestへ束縛されたcompleted responseをそのまま返す。
3319
+ if (known?.state === 'completed' && controlRequest.operation === 'activate'
3320
+ && !restarting) {
3321
+ if (known.request_digest !== controlRequest.request_digest) {
3322
+ throw new ManagedRuntimeError('REQUEST_ID_CONFLICT',
3323
+ '同一activate request_idへ異なるrequest digest');
3324
+ }
3325
+ return known.response;
3326
+ }
3316
3327
  if (known !== null && controlRequest.operation === 'activate') {
3317
3328
  const active = await resolveActiveRuntimePaths({ runDir }).catch(() => null);
3318
3329
  if (active?.pointer?.activation_request_id === controlRequest.request_id