gitnexushub 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,22 @@ export interface InstallOptions {
10
10
  homeDir?: string;
11
11
  /** Absolute path to the shipped hook script. */
12
12
  hookScriptPath: string;
13
+ /**
14
+ * Absolute path to the `node` binary that should run the hook
15
+ * script. Defaults to `process.execPath` — the node binary
16
+ * currently running the installer, which is the safest cross-
17
+ * platform guarantee that the hook spawn will find an interpreter.
18
+ *
19
+ * Why this matters: GUI editor processes (Cursor.app on macOS, Kiro
20
+ * launched from Dock) inherit a minimal `PATH` that often excludes
21
+ * `~/.local/node/bin`, `/opt/homebrew/bin`, etc. — wherever the
22
+ * user installed node. A hook command of `node "..."` then fails
23
+ * to spawn (`/bin/sh: node: command not found`) and the editor
24
+ * silently marks the hook as failed (Cursor renders this as a
25
+ * cosmetic red X in the Hooks tab; the hook never runs). Embedding
26
+ * the absolute path sidesteps PATH entirely. Tests can override.
27
+ */
28
+ nodePath?: string;
13
29
  }
14
30
  /**
15
31
  * Install the Claude Code PreToolUse + PostToolUse hooks.
@@ -38,19 +54,34 @@ export declare function installClaudeCodeHook(opts: InstallOptions): Promise<str
38
54
  */
39
55
  export declare function installCursorHook(opts: InstallOptions): Promise<string>;
40
56
  /**
41
- * Install the Kiro graph-context + staleness hooks.
42
- * Returns the absolute paths to both written hook files.
57
+ * Install Kiro hooks — graph-tools nudge (askAgent) + edit-closure
58
+ * capture (runCommand). Returns the absolute paths to both files.
43
59
  *
44
- * Kiro hooks (kiro.dev/docs/hooks/types) live as one JSON file per
45
- * hook under `.kiro/hooks/<name>.kiro.hook`. Trigger names are kebab-
46
- * case ("pre-tool-use" / "post-tool-use"), matching the Claude Code
47
- * PreToolUse/PostToolUse semantics. The gitnexus hook script handles
48
- * both — it normalises the kebab-case event name back to PascalCase
49
- * internally so a single script serves Kiro + Claude Code + Cursor +
50
- * OpenCode.
60
+ * Kiro's actual hook schema (verified against a Kiro-UI-generated
61
+ * file, kiro.dev/docs/hooks notwithstanding) differs in three places
62
+ * from what 0.7.0 shipped:
51
63
  *
52
- * We register two hook files because Kiro's hook spec is one trigger
53
- * per file; bundling both into a single file would silently lose one.
64
+ * 1. Top-level shape is `{when, then, version, name, enabled}`
65
+ * not the `{trigger, actions[]}` shape we were writing.
66
+ * 2. Trigger type values are camelCase (`postToolUse`) — not the
67
+ * kebab-case (`post-tool-use`) we emitted.
68
+ * 3. Action type for shell commands is `runCommand` — not `shell`.
69
+ *
70
+ * Plus three runtime constraints Kiro disclosed when asked directly:
71
+ *
72
+ * - `runCommand` actions get NO stdin payload, NO env vars for
73
+ * tool metadata, and NO `${variable}` substitution. So unlike
74
+ * Claude Code / Cursor, the script can't read the just-edited
75
+ * file path off stdin — we have to scan the working tree
76
+ * ourselves (see runKiroEditCapture in the hook script).
77
+ * - `runCommand` stdout is NOT fed back to the agent. Staleness
78
+ * nudges and graph-context augmentation are invisible via this
79
+ * channel — only `askAgent` actions can inject prompt text.
80
+ * - The graph-tools nudge therefore uses `askAgent`; capture uses
81
+ * `runCommand` plus the `--kiro-edit-capture` flag.
82
+ *
83
+ * `~/.kiro/hooks/` (user-level) IS loaded by Kiro despite some docs
84
+ * suggesting workspace-only — verified empirically in this session.
54
85
  */
55
86
  export declare function installKiroHook(opts: InstallOptions): Promise<string[]>;
56
87
  /**
@@ -11,6 +11,9 @@ import os from 'os';
11
11
  function getHome(opts) {
12
12
  return opts.homeDir ?? os.homedir();
13
13
  }
14
+ function getNode(opts) {
15
+ return opts.nodePath ?? process.execPath;
16
+ }
14
17
  async function writeJsonIdempotent(filePath, obj) {
15
18
  await fs.mkdir(path.dirname(filePath), { recursive: true });
16
19
  const body = JSON.stringify(obj, null, 2) + '\n';
@@ -61,7 +64,7 @@ export async function installClaudeCodeHook(opts) {
61
64
  // script which editor it's running under so /api/activity captures
62
65
  // get the right agentName (the stdin payload alone can't
63
66
  // distinguish editors that share the PascalCase MCP event names).
64
- const command = `node ${shellQuote(opts.hookScriptPath)} --agent=claude-code`;
67
+ const command = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=claude-code`;
65
68
  const config = {
66
69
  hooks: {
67
70
  PreToolUse: [
@@ -116,7 +119,7 @@ export async function installClaudeCodeHook(opts) {
116
119
  export async function installCursorHook(opts) {
117
120
  const home = getHome(opts);
118
121
  const hooksJsonPath = path.join(home, '.cursor', 'hooks.json');
119
- const command = `node ${shellQuote(opts.hookScriptPath)} --agent=cursor`;
122
+ const command = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=cursor`;
120
123
  // Merge into the user's existing ~/.cursor/hooks.json instead of
121
124
  // clobbering it — that file is the GLOBAL Cursor hooks config and
122
125
  // may already carry the user's own audit / lint hooks. Idempotent:
@@ -143,42 +146,70 @@ export async function installCursorHook(opts) {
143
146
  return hooksJsonPath;
144
147
  }
145
148
  /**
146
- * Install the Kiro graph-context + staleness hooks.
147
- * Returns the absolute paths to both written hook files.
149
+ * Install Kiro hooks — graph-tools nudge (askAgent) + edit-closure
150
+ * capture (runCommand). Returns the absolute paths to both files.
151
+ *
152
+ * Kiro's actual hook schema (verified against a Kiro-UI-generated
153
+ * file, kiro.dev/docs/hooks notwithstanding) differs in three places
154
+ * from what 0.7.0 shipped:
155
+ *
156
+ * 1. Top-level shape is `{when, then, version, name, enabled}` —
157
+ * not the `{trigger, actions[]}` shape we were writing.
158
+ * 2. Trigger type values are camelCase (`postToolUse`) — not the
159
+ * kebab-case (`post-tool-use`) we emitted.
160
+ * 3. Action type for shell commands is `runCommand` — not `shell`.
148
161
  *
149
- * Kiro hooks (kiro.dev/docs/hooks/types) live as one JSON file per
150
- * hook under `.kiro/hooks/<name>.kiro.hook`. Trigger names are kebab-
151
- * case ("pre-tool-use" / "post-tool-use"), matching the Claude Code
152
- * PreToolUse/PostToolUse semantics. The gitnexus hook script handles
153
- * both — it normalises the kebab-case event name back to PascalCase
154
- * internally so a single script serves Kiro + Claude Code + Cursor +
155
- * OpenCode.
162
+ * Plus three runtime constraints Kiro disclosed when asked directly:
156
163
  *
157
- * We register two hook files because Kiro's hook spec is one trigger
158
- * per file; bundling both into a single file would silently lose one.
164
+ * - `runCommand` actions get NO stdin payload, NO env vars for
165
+ * tool metadata, and NO `${variable}` substitution. So unlike
166
+ * Claude Code / Cursor, the script can't read the just-edited
167
+ * file path off stdin — we have to scan the working tree
168
+ * ourselves (see runKiroEditCapture in the hook script).
169
+ * - `runCommand` stdout is NOT fed back to the agent. Staleness
170
+ * nudges and graph-context augmentation are invisible via this
171
+ * channel — only `askAgent` actions can inject prompt text.
172
+ * - The graph-tools nudge therefore uses `askAgent`; capture uses
173
+ * `runCommand` plus the `--kiro-edit-capture` flag.
174
+ *
175
+ * `~/.kiro/hooks/` (user-level) IS loaded by Kiro despite some docs
176
+ * suggesting workspace-only — verified empirically in this session.
159
177
  */
160
178
  export async function installKiroHook(opts) {
161
179
  const home = getHome(opts);
162
180
  const hooksDir = path.join(home, '.kiro', 'hooks');
163
- // `--agent=kiro` tags /api/activity captures so distillation can
164
- // split rows by editor — Kiro's kebab-case events would also let
165
- // detectAgent infer "kiro", but explicit > inferred.
166
- const cmd = `node ${shellQuote(opts.hookScriptPath)} --agent=kiro`;
167
- const preHookPath = path.join(hooksDir, 'gitnexus-graph-context.kiro.hook');
168
- const postHookPath = path.join(hooksDir, 'gitnexus-staleness-check.kiro.hook');
169
- await writeJsonIdempotent(preHookPath, {
170
- name: 'gitnexus-graph-context',
171
- description: 'Inject GitNexus graph context (callers, callees, impact) before Grep/Glob/Bash tool calls',
172
- trigger: { type: 'pre-tool-use' },
173
- actions: [{ type: 'shell', command: cmd }],
181
+ const editCaptureCmd = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=kiro --kiro-edit-capture`;
182
+ const preferGraphHookPath = path.join(hooksDir, 'gitnexus-prefer-graph-tools.kiro.hook');
183
+ const editCaptureHookPath = path.join(hooksDir, 'gitnexus-edit-capture.kiro.hook');
184
+ // 1. Steer the agent toward graph tools before shell searches.
185
+ // askAgent is the only Kiro action whose prompt actually reaches
186
+ // the agent context; runCommand stdout is discarded.
187
+ await writeJsonIdempotent(preferGraphHookPath, {
188
+ name: 'gitnexus-prefer-graph-tools',
189
+ version: '1',
190
+ enabled: true,
191
+ description: 'Before running shell searches (grep/find/rg), prefer GitNexus graph tools for richer code context',
192
+ when: { type: 'preToolUse', toolTypes: ['shell'] },
193
+ then: {
194
+ type: 'askAgent',
195
+ prompt: "Before running this shell command: if it's a search (grep, find, glob, rg), prefer the GitNexus MCP tools instead — `gitnexus_query` for concept search, `gitnexus_context` for symbol lookup, `gitnexus_cypher` for structural queries. Only fall back to shell search if GitNexus can't answer it.",
196
+ },
174
197
  });
175
- await writeJsonIdempotent(postHookPath, {
176
- name: 'gitnexus-staleness-check',
177
- description: 'Capture file edits (edit-closure) and nudge to gnx sync after git commit/merge/rebase',
178
- trigger: { type: 'post-tool-use' },
179
- actions: [{ type: 'shell', command: cmd }],
198
+ // 2. Edit-closure capture. Fires after every write tool. Hook
199
+ // script runs `git status --porcelain` in the workspace root,
200
+ // posts each dirty file to /api/activity/edit-observed. Coarser
201
+ // than Claude Code / Cursor (catches all dirty files, not just
202
+ // the one the agent just touched) — but it's the only signal
203
+ // Kiro's hook plumbing exposes.
204
+ await writeJsonIdempotent(editCaptureHookPath, {
205
+ name: 'gitnexus-edit-capture',
206
+ version: '1',
207
+ enabled: true,
208
+ description: 'Capture file edits for distillation by scanning the working tree after write tool calls',
209
+ when: { type: 'postToolUse', toolTypes: ['write'] },
210
+ then: { type: 'runCommand', command: editCaptureCmd, timeout: 5 },
180
211
  });
181
- return [preHookPath, postHookPath];
212
+ return [preferGraphHookPath, editCaptureHookPath];
182
213
  }
183
214
  /**
184
215
  * Install the OpenCode plugin stub.
@@ -389,23 +389,67 @@ function normaliseToolName(raw) {
389
389
  }
390
390
 
391
391
  /**
392
- * Emit the response in the format the source editor expects. Three
392
+ * Parse a `tool_output` field that may be either an object (Claude
393
+ * Code / Kiro / OpenCode native shape) or a JSON-stringified blob
394
+ * (Cursor wraps the result as a string per cursor.com/docs/hooks).
395
+ * Returns the parsed object, or the original value if it isn't a
396
+ * string we can decode. Caller still has to check for null.
397
+ */
398
+ function parseToolOutput(out) {
399
+ if (typeof out === 'string') {
400
+ try {
401
+ return JSON.parse(out);
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /**
410
+ * Get the exit code from a normalised tool_output, accepting either
411
+ * `exit_code` (Claude Code / snake_case) or `exitCode` (Cursor /
412
+ * camelCase). Returns null when neither is present so callers can
413
+ * treat that as "succeeded" (some editors omit exit info entirely).
414
+ */
415
+ function readExitCode(out) {
416
+ if (!out || typeof out !== 'object') return null;
417
+ if (typeof out.exit_code === 'number') return out.exit_code;
418
+ if (typeof out.exitCode === 'number') return out.exitCode;
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Emit the response in the format the source editor expects. Four
393
424
  * envelopes today, one script:
394
425
  *
395
426
  * Claude Code / OpenCode → `hookSpecificOutput` envelope
396
427
  * ({ hookSpecificOutput: { hookEventName, additionalContext } })
397
- * Cursor 2.4+ → `additional_context` (snake_case top-level)
398
- * ({ additional_context: "..." }) see cursor.com/docs/hooks
399
- * Kiro IDE plain stdout (kiro.dev/docs/hooks/actions
400
- * "the stdout output of the command is added to the agent's context")
428
+ * Cursor 2.4+ preToolUse → `permission` field is REQUIRED (cursor.com/docs/hooks)
429
+ * plus optional `additional_context` for prompt injection
430
+ * Cursor 2.4+ postToolUse `additional_context` only
431
+ * Kiro IDE → plain stdout (kiro.dev/docs/hooks/actions
432
+ * except `runCommand` actions are fire-and-forget; stdout is NOT
433
+ * fed back into agent context per Kiro's own clarification)
401
434
  */
402
435
  function sendResponse(event, agentName, message) {
403
436
  if (agentName === 'kiro') {
437
+ // Kiro `runCommand` action discards stdout. Writing it is harmless
438
+ // — it just doesn't reach the agent. We still emit so manual
439
+ // testing / log inspection has something to read.
404
440
  process.stdout.write(message + '\n');
405
441
  return;
406
442
  }
407
443
  if (agentName === 'cursor') {
408
- process.stdout.write(JSON.stringify({ additional_context: message }) + '\n');
444
+ // Cursor preToolUse rejects entries that don't include `permission`
445
+ // — without it the Hooks tab logs the call as failed (cosmetic red
446
+ // X) even when our capture POST succeeded behind the scenes. We
447
+ // always allow; gating tool calls isn't gitnexus's job.
448
+ const envelope =
449
+ event === 'PreToolUse'
450
+ ? { permission: 'allow', additional_context: message }
451
+ : { additional_context: message };
452
+ process.stdout.write(JSON.stringify(envelope) + '\n');
409
453
  return;
410
454
  }
411
455
  process.stdout.write(
@@ -415,23 +459,48 @@ function sendResponse(event, agentName, message) {
415
459
  );
416
460
  }
417
461
 
462
+ /**
463
+ * Cursor preToolUse with no augmentation message still has to return
464
+ * `permission: "allow"` or the IDE logs the call as failed. Use this
465
+ * when the script's other handlers decide not to inject context but
466
+ * we still need to clear the permission gate.
467
+ */
468
+ function sendCursorAllow() {
469
+ process.stdout.write(JSON.stringify({ permission: 'allow' }) + '\n');
470
+ }
471
+
418
472
  async function handlePreToolUse(input, config, entry, agentName) {
419
473
  const toolName = normaliseToolName(input.tool_name);
420
- if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
421
-
422
- const pattern = extractPattern(toolName, input.tool_input || {});
423
- if (!pattern || pattern.length < 3) return;
424
474
 
425
- const res = await httpPostJson(
426
- `${config.hubUrl}/api/repos/${entry.hubRepoId}/augment`,
427
- authHeaders(config),
428
- { pattern },
429
- );
430
- if (!res || res.status !== 200 || !res.body || !res.body.text) return;
475
+ // Cursor's preToolUse matcher includes Read + Write so the hook
476
+ // also fires for those, but our augment flow only handles search
477
+ // tools. Track whether we actually injected context so we can
478
+ // satisfy Cursor's permission contract on the no-augment path.
479
+ let injected = false;
480
+
481
+ if (toolName === 'Grep' || toolName === 'Glob' || toolName === 'Bash') {
482
+ const pattern = extractPattern(toolName, input.tool_input || {});
483
+ if (pattern && pattern.length >= 3) {
484
+ const res = await httpPostJson(
485
+ `${config.hubUrl}/api/repos/${entry.hubRepoId}/augment`,
486
+ authHeaders(config),
487
+ { pattern },
488
+ );
489
+ if (res && res.status === 200 && res.body && res.body.text) {
490
+ const text = String(res.body.text).trim();
491
+ if (text) {
492
+ sendResponse('PreToolUse', agentName, text);
493
+ injected = true;
494
+ }
495
+ }
496
+ }
497
+ }
431
498
 
432
- const text = String(res.body.text).trim();
433
- if (!text) return;
434
- sendResponse('PreToolUse', agentName, text);
499
+ // Cursor preToolUse REQUIRES `permission` in the response; without
500
+ // it the Hooks tab marks every fire as failed (red X). Allow the
501
+ // tool unconditionally when we have nothing else to say — gating
502
+ // tool execution isn't gitnexus's responsibility.
503
+ if (!injected && agentName === 'cursor') sendCursorAllow();
435
504
  }
436
505
 
437
506
  /**
@@ -473,10 +542,15 @@ async function handleEditObservation(input, config, entry, agentName) {
473
542
  if (tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return;
474
543
 
475
544
  // Only fire when the edit succeeded. Some editors omit exit info; treat
476
- // missing as success so we don't drop legitimate edits.
477
- const out = input.tool_output;
478
- if (out && out.exit_code !== undefined && out.exit_code !== 0) return;
479
- if (out && out.error) return;
545
+ // missing as success so we don't drop legitimate edits. Cursor wraps
546
+ // tool_output as a JSON-stringified blob; everyone else passes an
547
+ // object parseToolOutput normalises both. Cursor uses `exitCode`
548
+ // (camel) while Claude Code uses `exit_code` (snake) — readExitCode
549
+ // accepts either.
550
+ const out = parseToolOutput(input.tool_output);
551
+ const exitCode = readExitCode(out);
552
+ if (exitCode !== null && exitCode !== 0) return;
553
+ if (out && typeof out === 'object' && out.error) return;
480
554
 
481
555
  const filePath = (input.tool_input && input.tool_input.file_path) || '';
482
556
  if (!filePath || typeof filePath !== 'string') return;
@@ -511,11 +585,12 @@ async function handlePostToolUse(input, config, entry, agentName) {
511
585
  const cmd = (input.tool_input && input.tool_input.command) || '';
512
586
  if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(cmd)) return;
513
587
 
514
- // Only nudge when the git command actually succeeded. Some editors
515
- // omit exit_code; treat missing as success so we don't silently
516
- // skip legitimate mutations.
517
- const exitCode = input.tool_output && input.tool_output.exit_code;
518
- if (exitCode !== undefined && exitCode !== 0) return;
588
+ // Only nudge when the git command actually succeeded. Same parse
589
+ // shenanigans as handleEditObservation Cursor stringifies and uses
590
+ // camelCase. Treat missing exit info as success.
591
+ const out = parseToolOutput(input.tool_output);
592
+ const exitCode = readExitCode(out);
593
+ if (exitCode !== null && exitCode !== 0) return;
519
594
 
520
595
  let localHead = '';
521
596
  try {
@@ -633,9 +708,147 @@ async function maybeNudgeUpgrade(config) {
633
708
  }
634
709
  }
635
710
 
711
+ /**
712
+ * Kiro edit-capture mode. Kiro's `runCommand` action provides no stdin
713
+ * payload, no env vars for tool metadata, and no argument substitution
714
+ * — so we can't know which file the agent just edited. Instead, fire
715
+ * this on Kiro's `postToolUse` toolTypes:["write"] hook and scan the
716
+ * git working tree for changed files via `git status --porcelain`.
717
+ * Each dirty path POSTs to /api/activity/edit-observed so distillation
718
+ * gets per-file edit-closure data, even though the precision is
719
+ * coarser than Claude Code / Cursor (catches all dirty files in the
720
+ * tree, not just the one the agent just touched).
721
+ *
722
+ * cwd defaults to process.cwd() which Kiro sets to the workspace root.
723
+ * Resolving to a registry repo + reading the auth token mirrors the
724
+ * standard PostToolUse path.
725
+ */
726
+ async function runKiroEditCapture(agentName) {
727
+ const config = readConfig();
728
+ if (!config || !config.hubToken || !config.hubUrl) return;
729
+
730
+ const entries = readRegistry();
731
+ const cwd = process.cwd();
732
+
733
+ // Pick which repos to scan. Two cases:
734
+ //
735
+ // A. cwd is INSIDE a registered repo (e.g. Kiro opened directly
736
+ // on the project root, or any child path). Standard
737
+ // resolveCwdToRepo path — scan that single repo.
738
+ //
739
+ // B. cwd is the PARENT of one or more registered repos (e.g.
740
+ // Kiro opened on `~/AkonLabs/` with multiple sub-repos
741
+ // inside). resolveCwdToRepo returns null in this case
742
+ // because the cwd↔localPath direction is reversed. Find all
743
+ // registered repos whose localPath is inside cwd and scan
744
+ // each. The single-repo case (A) takes precedence so we
745
+ // don't double-scan when both relations match.
746
+ let resolvedCwd;
747
+ try {
748
+ resolvedCwd = fs.realpathSync(path.resolve(cwd));
749
+ } catch {
750
+ resolvedCwd = path.resolve(cwd);
751
+ }
752
+ const isWin = process.platform === 'win32';
753
+ const normCwd = isWin ? resolvedCwd.toLowerCase() : resolvedCwd;
754
+ const sep = path.sep;
755
+
756
+ /** @type {Array<{ hubRepoId: string, localPath: string }>} */
757
+ const targets = [];
758
+ const inside = resolveCwdToRepo(cwd, entries);
759
+ if (inside) {
760
+ targets.push({ hubRepoId: inside.hubRepoId, localPath: inside.localPath });
761
+ } else {
762
+ for (const entry of entries) {
763
+ if (!entry || !entry.localPath || !entry.hubRepoId) continue;
764
+ let ep;
765
+ try {
766
+ ep = fs.realpathSync(path.resolve(entry.localPath));
767
+ } catch {
768
+ ep = path.resolve(entry.localPath);
769
+ }
770
+ const nep = isWin ? ep.toLowerCase() : ep;
771
+ if (nep === normCwd || nep.startsWith(normCwd + sep)) {
772
+ targets.push({ hubRepoId: entry.hubRepoId, localPath: ep });
773
+ }
774
+ }
775
+ }
776
+ if (targets.length === 0) return;
777
+
778
+ // Run git diff + ls-files inside each target repo. Cap at 5 repos
779
+ // per fire so a `~/code/` workspace with 50 sibling repos doesn't
780
+ // spawn 100 git processes on every postToolUse. The 20-file cap
781
+ // is applied per-repo.
782
+ let postedTotal = 0;
783
+ const MAX_REPOS = 5;
784
+ const MAX_FILES_PER_REPO = 20;
785
+ for (const target of targets.slice(0, MAX_REPOS)) {
786
+ const collected = new Set();
787
+ for (const args of [
788
+ ['diff', '--name-only', 'HEAD'],
789
+ ['ls-files', '--others', '--exclude-standard'],
790
+ ]) {
791
+ try {
792
+ const r = spawnSync('git', args, {
793
+ cwd: target.localPath,
794
+ encoding: 'utf-8',
795
+ timeout: 2000,
796
+ });
797
+ const out = (r.stdout || '').trim();
798
+ if (!out) continue;
799
+ for (const line of out.split('\n')) {
800
+ const trimmed = line.trim();
801
+ if (trimmed) collected.add(trimmed);
802
+ }
803
+ } catch {
804
+ // Best effort
805
+ }
806
+ }
807
+ if (collected.size === 0) continue;
808
+
809
+ let perRepo = 0;
810
+ for (const filePath of collected) {
811
+ if (perRepo >= MAX_FILES_PER_REPO) break;
812
+ const absPath = path.isAbsolute(filePath) ? filePath : path.join(target.localPath, filePath);
813
+ await httpPostJson(`${config.hubUrl}/api/activity/edit-observed`, authHeaders(config), {
814
+ sessionId: null,
815
+ repoId: target.hubRepoId,
816
+ filePath: absPath,
817
+ line: null,
818
+ tool: 'Write',
819
+ agentName,
820
+ });
821
+ perRepo++;
822
+ postedTotal++;
823
+ }
824
+ }
825
+
826
+ if (process.env.GITNEXUS_DEBUG) {
827
+ process.stderr.write(
828
+ `kiro-edit-capture: targets=${targets.length} posted=${postedTotal}\n`,
829
+ );
830
+ }
831
+ }
832
+
636
833
  async function main() {
637
834
  if (process.env.GITNEXUS_NO_AUGMENT === '1') return;
638
835
 
836
+ // Kiro short-circuit: when invoked with --kiro-edit-capture, skip
837
+ // the standard stdin-driven flow entirely (Kiro's runCommand
838
+ // doesn't pipe stdin) and run the porcelain scan instead. The
839
+ // installer wires this flag onto Kiro's postToolUse:write hook.
840
+ if (process.argv.slice(2).includes('--kiro-edit-capture')) {
841
+ const agentName = parseAgentArg() || 'kiro';
842
+ try {
843
+ await runKiroEditCapture(agentName);
844
+ } catch (err) {
845
+ if (process.env.GITNEXUS_DEBUG) {
846
+ process.stderr.write(`kiro edit-capture: ${(err && err.message) || String(err)}\n`);
847
+ }
848
+ }
849
+ return;
850
+ }
851
+
639
852
  const input = readInput();
640
853
  const rawEvent = input.hook_event_name;
641
854
  const event = normaliseEvent(rawEvent);
@@ -654,9 +867,26 @@ async function main() {
654
867
  }
655
868
 
656
869
  const entries = readRegistry();
657
- const cwd = input.cwd || process.cwd();
870
+ // Cursor 3.x spawns hooks from ~/.cursor (NOT the workspace) and
871
+ // sets `cwd: ""` in the stdin payload. The actual project root is
872
+ // in `workspace_roots[0]`. Without this fallback, resolveCwdToRepo
873
+ // looks for a registry entry at ~/.cursor → no match → script
874
+ // bails before posting /api/activity/edit-observed. Other editors
875
+ // (Claude Code, Kiro) set `cwd` themselves, so the fallback chain
876
+ // hits their value first.
877
+ const cwd =
878
+ (Array.isArray(input.workspace_roots) && input.workspace_roots[0]) ||
879
+ input.cwd ||
880
+ process.cwd();
658
881
  const entry = resolveCwdToRepo(cwd, entries);
659
- if (!entry) return;
882
+ if (!entry) {
883
+ // Cursor preToolUse must still emit `permission: "allow"` even
884
+ // when we have nothing else to say — without it the IDE flags the
885
+ // hook entry as failed (cosmetic red X). All other paths exit
886
+ // silently as before.
887
+ if (event === 'PreToolUse' && agentName === 'cursor') sendCursorAllow();
888
+ return;
889
+ }
660
890
 
661
891
  try {
662
892
  if (event === 'PreToolUse') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexushub",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Connect your editor to GitNexus Hub — one command MCP setup + project context",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",