@quolu/lattice 0.63.10 → 0.64.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.63.10",
3
+ "version": "0.64.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
@@ -201,7 +201,7 @@ Commands:
201
201
  registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPTが
202
202
  両方設定されている時だけ動く。アドレスは送らず、remote側がssh送信元から決める。
203
203
  `,
204
- hooks: `Usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>
204
+ hooks: `Usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex|cursor>
205
205
  `,
206
206
  });
207
207
 
@@ -297,10 +297,10 @@ const SUBCOMMAND_USAGE = Object.freeze({
297
297
  'bridge status': 'bridge status --json',
298
298
  'bridge disable': 'bridge disable --json',
299
299
  'bridge register': 'bridge register --json',
300
- 'hooks install': 'hooks install --host <claude|codex>',
301
- 'hooks status': 'hooks status --host <claude|codex>',
302
- 'hooks uninstall': 'hooks uninstall --host <claude|codex>',
303
- 'hooks emit': 'hooks emit --host <claude|codex>',
300
+ 'hooks install': 'hooks install --host <claude|codex|cursor>',
301
+ 'hooks status': 'hooks status --host <claude|codex|cursor>',
302
+ 'hooks uninstall': 'hooks uninstall --host <claude|codex|cursor>',
303
+ 'hooks emit': 'hooks emit --host <claude|codex|cursor>',
304
304
  });
305
305
 
306
306
  function requestedNamespace(argv) {
package/src/hooks-cli.mjs CHANGED
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
12
  const INFO = 'INFO: このrepoにはLattice sensor index(.lattice/sensor/)があります。コード構造の調査はsensor入口(MCP: lattice_sensor_explore 等/CLI: lattice sensor)を優先できます。';
13
- const HOSTS = new Set(['claude', 'codex']);
13
+ const HOSTS = new Set(['claude', 'codex', 'cursor']);
14
+ const HOST_USAGE = 'usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex|cursor>';
14
15
  const MAX_STDIN_BYTES = 64 * 1024;
15
16
  const SHOWN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
17
  const CLAIM_MAX_AGE_MS = 60 * 60 * 1000;
@@ -32,7 +33,22 @@ function failure(stdout, code, message, exit = 1, detail) {
32
33
  }
33
34
 
34
35
  function configPath(home, host) {
35
- return path.join(home, host === 'claude' ? '.claude/settings.json' : '.codex/hooks.json');
36
+ if (host === 'claude') return path.join(home, '.claude/settings.json');
37
+ if (host === 'cursor') return path.join(home, '.cursor/hooks.json');
38
+ return path.join(home, '.codex/hooks.json');
39
+ }
40
+
41
+ function eventName(host) {
42
+ return host === 'cursor' ? 'beforeSubmitPrompt' : 'UserPromptSubmit';
43
+ }
44
+
45
+ function isFlatHost(host) {
46
+ return host === 'cursor';
47
+ }
48
+
49
+ function isCommandHandler(item, host) {
50
+ if (item === null || typeof item !== 'object' || typeof item.command !== 'string') return false;
51
+ return isFlatHost(host) || item.type === 'command';
36
52
  }
37
53
 
38
54
  function stateBase(env) {
@@ -314,15 +330,16 @@ async function withReceiptLock(env, host, operation) {
314
330
  }
315
331
  }
316
332
 
317
- function allHandlers(value) {
318
- const list = value?.hooks?.UserPromptSubmit;
333
+ function allHandlers(value, host) {
334
+ const list = value?.hooks?.[eventName(host)];
319
335
  if (!Array.isArray(list)) return [];
336
+ if (isFlatHost(host)) return list;
320
337
  return list.flatMap((wrapper) => Array.isArray(wrapper?.hooks) ? wrapper.hooks : []);
321
338
  }
322
339
 
323
- function configContains(value, argv) {
324
- return allHandlers(value).some((item) => item?.type === 'command'
325
- && typeof item.command === 'string' && commandIs(item.command, [argv]));
340
+ function configContains(value, argv, host) {
341
+ return allHandlers(value, host).some((item) => isCommandHandler(item, host)
342
+ && commandIs(item.command, [argv]));
326
343
  }
327
344
 
328
345
  async function recoverReceipt(env, host, configTarget, testHooks) {
@@ -335,7 +352,7 @@ async function recoverReceipt(env, host, configTarget, testHooks) {
335
352
  const config = await readConfig(configTarget);
336
353
  const entries = receipt.entries.flatMap((entry) => {
337
354
  if (entry.status !== 'pending') return [entry];
338
- return configContains(config.value, entry.argv) ? [{ ...entry, status: 'committed' }] : [];
355
+ return configContains(config.value, entry.argv, host) ? [{ ...entry, status: 'committed' }] : [];
339
356
  });
340
357
  const recovered = { ...receipt, entries };
341
358
  await writeReceiptUnlocked(target, recovered);
@@ -390,26 +407,37 @@ async function readConfig(target) {
390
407
  || Array.isArray(value.hooks))) {
391
408
  throw Object.assign(new Error('hooks is not an object'), { code: 'CONFIG_INVALID' });
392
409
  }
393
- if (value.hooks?.UserPromptSubmit !== undefined && !Array.isArray(value.hooks.UserPromptSubmit)) {
394
- throw Object.assign(new Error('UserPromptSubmit is not an array'), { code: 'CONFIG_INVALID' });
410
+ for (const name of ['UserPromptSubmit', 'beforeSubmitPrompt']) {
411
+ if (value.hooks?.[name] !== undefined && !Array.isArray(value.hooks[name])) {
412
+ throw Object.assign(new Error(`${name} is not an array`), { code: 'CONFIG_INVALID' });
413
+ }
395
414
  }
396
415
  return { existed: true, mode: info.mode & 0o777, bytes, value };
397
416
  }
398
417
 
399
- function hooksList(value) {
418
+ function hooksList(value, host) {
419
+ const name = eventName(host);
400
420
  if (value.hooks === undefined) value.hooks = {};
401
- if (value.hooks.UserPromptSubmit === undefined) value.hooks.UserPromptSubmit = [];
402
- return value.hooks.UserPromptSubmit;
421
+ if (value.hooks[name] === undefined) value.hooks[name] = [];
422
+ return value.hooks[name];
403
423
  }
404
424
 
405
- function stripIdentity(value, identities) {
425
+ function stripIdentity(value, identities, host) {
406
426
  let removed = 0;
407
- const list = hooksList(value);
408
- value.hooks.UserPromptSubmit = list.flatMap((wrapper) => {
427
+ const name = eventName(host);
428
+ const list = hooksList(value, host);
429
+ if (isFlatHost(host)) {
430
+ value.hooks[name] = list.filter((item) => {
431
+ const owned = isCommandHandler(item, host) && commandIs(item.command, identities);
432
+ if (owned) removed += 1;
433
+ return !owned;
434
+ });
435
+ return removed;
436
+ }
437
+ value.hooks[name] = list.flatMap((wrapper) => {
409
438
  if (!wrapper || !Array.isArray(wrapper.hooks)) return [wrapper];
410
439
  const handlers = wrapper.hooks.filter((item) => {
411
- const owned = item?.type === 'command' && typeof item.command === 'string'
412
- && commandIs(item.command, identities);
440
+ const owned = isCommandHandler(item, host) && commandIs(item.command, identities);
413
441
  if (owned) removed += 1;
414
442
  return !owned;
415
443
  });
@@ -419,9 +447,9 @@ function stripIdentity(value, identities) {
419
447
  }
420
448
 
421
449
  function hostHandler(host, command) {
422
- return host === 'claude'
423
- ? { type: 'command', command, timeout: 5 }
424
- : { type: 'command', command, timeout: 5, async: false, statusMessage: null };
450
+ if (host === 'claude') return { type: 'command', command, timeout: 5 };
451
+ if (host === 'cursor') return { command, timeout: 5 };
452
+ return { type: 'command', command, timeout: 5, async: false, statusMessage: null };
425
453
  }
426
454
 
427
455
  export async function resolveStableNodePath(execPath, {
@@ -655,11 +683,17 @@ async function mutate(host, env, stdout, uninstall, source, platform, testHooks)
655
683
  }
656
684
  const identities = [current, ...committedIdentities(receipt)];
657
685
  const next = structuredClone(prestate.value);
658
- const removed = stripIdentity(next, identities);
659
- if (!uninstall) hooksList(next).push({ hooks: [hostHandler(host, shell(current))] });
686
+ if (host === 'cursor' && next.version === undefined) next.version = 1;
687
+ const removed = stripIdentity(next, identities, host);
688
+ if (!uninstall) {
689
+ const entry = hostHandler(host, shell(current));
690
+ if (isFlatHost(host)) hooksList(next, host).push(entry);
691
+ else hooksList(next, host).push({ hooks: [entry] });
692
+ }
660
693
 
661
- const currentHandlers = allHandlers(prestate.value).filter((item) => item?.type === 'command'
662
- && typeof item.command === 'string' && commandIs(item.command, identities));
694
+ const currentHandlers = allHandlers(prestate.value, host).filter((item) => (
695
+ isCommandHandler(item, host) && commandIs(item.command, identities)
696
+ ));
663
697
  const alreadyWired = !uninstall && currentHandlers.length === 1
664
698
  && commandIs(currentHandlers[0].command, [current])
665
699
  && exactHandler(currentHandlers[0], hostHandler(host, shell(current)));
@@ -755,8 +789,8 @@ async function status(host, env, stdout, source, platform, testHooks) {
755
789
  let canonicalMatches = 0;
756
790
  let foreign = 0;
757
791
  let canonicalShape = false;
758
- for (const item of allHandlers(config.value)) {
759
- if (item?.type !== 'command' || typeof item.command !== 'string') continue;
792
+ for (const item of allHandlers(config.value, host)) {
793
+ if (!isCommandHandler(item, host)) continue;
760
794
  if (commandIs(item.command, identities)) {
761
795
  matches += 1;
762
796
  if (commandIs(item.command, [argv])) {
@@ -805,6 +839,20 @@ async function diagnose(state, stdout, message, diagnostic) {
805
839
  stdout.write(`Lattice hooks: ${diagnostic}\n`);
806
840
  }
807
841
 
842
+ function sessionIdFromEvent(event) {
843
+ for (const key of ['session_id', 'conversation_id']) {
844
+ if (typeof event?.[key] === 'string' && event[key].length > 0) return event[key];
845
+ }
846
+ return null;
847
+ }
848
+
849
+ function cwdFromEvent(event) {
850
+ if (typeof event?.cwd === 'string' && path.isAbsolute(event.cwd)) return event.cwd;
851
+ const root = event?.workspace_roots?.[0];
852
+ if (typeof root === 'string' && path.isAbsolute(root)) return root;
853
+ return null;
854
+ }
855
+
808
856
  async function readHookInput(stdin) {
809
857
  const chunks = [];
810
858
  let length = 0;
@@ -820,14 +868,15 @@ async function readHookInput(stdin) {
820
868
  try { event = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
821
869
  throw new Error('hook stdin is not strict JSON');
822
870
  }
823
- if (typeof event?.session_id !== 'string' || event.session_id.length === 0
824
- || typeof event.cwd !== 'string' || !path.isAbsolute(event.cwd)) {
871
+ const sessionId = sessionIdFromEvent(event);
872
+ const cwdValue = cwdFromEvent(event);
873
+ if (sessionId === null || cwdValue === null) {
825
874
  throw new Error('hook stdin requires session_id and absolute cwd');
826
875
  }
827
876
  try {
828
- const cwd = await realpath(event.cwd);
877
+ const cwd = await realpath(cwdValue);
829
878
  if (!(await lstat(cwd)).isDirectory()) throw new Error('cwd is not a directory');
830
- return { ...event, cwd };
879
+ return { ...event, session_id: sessionId, cwd };
831
880
  } catch {
832
881
  throw new Error('hook cwd is unavailable');
833
882
  }
@@ -925,7 +974,9 @@ async function acquireClaim(claim) {
925
974
  }
926
975
 
927
976
  function outputLine(host) {
928
- return host === 'claude' ? `${INFO}\n` : `${JSON.stringify({
977
+ if (host === 'claude') return `${INFO}\n`;
978
+ if (host === 'cursor') return `${JSON.stringify({ additional_context: INFO })}\n`;
979
+ return `${JSON.stringify({
929
980
  hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: INFO },
930
981
  })}\n`;
931
982
  }
@@ -1035,8 +1086,7 @@ export async function runHooksCli({
1035
1086
  }) {
1036
1087
  if (argv.length !== 3 || !['install', 'status', 'uninstall', 'emit'].includes(argv[0])
1037
1088
  || argv[1] !== '--host' || !HOSTS.has(argv[2])) {
1038
- return failure(stdout, 'USAGE',
1039
- 'usage: lattice hooks <install|status|uninstall|emit> --host <claude|codex>', 2);
1089
+ return failure(stdout, 'USAGE', HOST_USAGE, 2);
1040
1090
  }
1041
1091
  const [command, , host] = argv;
1042
1092
  if (platform === 'win32') {