nearly-cli 0.1.11 → 0.1.12

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": "nearly-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -98,7 +98,14 @@ function parseRecording(path) {
98
98
  // belongs to nobody.
99
99
  function samePath(a, b) {
100
100
  if (!a || !b) return false;
101
- const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
101
+ const real = (p) => {
102
+ let r;
103
+ try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
104
+ // Windows spells the same directory more than one way and means the same
105
+ // place. Comparing those as strings loses every session on that machine,
106
+ // the same way /var against /private/var did on this one.
107
+ return process.platform === 'win32' ? r.toLowerCase() : r;
108
+ };
102
109
  return real(a) === real(b);
103
110
  }
104
111
 
@@ -61,6 +61,50 @@ const gated = ADAPTERS.filter((a) => {
61
61
  if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
62
62
  else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
63
63
 
64
+ // Configured is not the same as working, and the difference is invisible.
65
+ // Hooks fail open on purpose — a broken one must never wedge an agent — so a
66
+ // command that cannot be found produces silence, and silence looks exactly like
67
+ // a session nobody ran. Everything above can be green while nothing is gated.
68
+ //
69
+ // So run the hook this repo actually has, the way the agent runs it, and see
70
+ // whether an answer comes back.
71
+ if (gated.length) {
72
+ const cc = gated.find((a) => a.id === 'claude-code') || gated[0];
73
+ let cmd = null;
74
+ try {
75
+ const cfg = JSON.parse(readFileSync(join(repo, cc.config), 'utf8'));
76
+ const walk = (o) => {
77
+ if (!o || typeof o !== 'object') return;
78
+ if (typeof o.command === 'string' && /nearly/i.test(o.command) && /pre-tool/.test(o.command)) cmd = o.command;
79
+ for (const v of Object.values(o)) walk(v);
80
+ };
81
+ walk(cfg);
82
+ } catch { /* unreadable config */ }
83
+
84
+ if (!cmd) {
85
+ say(null, 'hooks actually fire', 'could not find the pre-tool hook to try');
86
+ } else {
87
+ const probe = JSON.stringify({
88
+ session_id: `nearly-doctor-${Date.now()}`, cwd: repo,
89
+ hook_event_name: 'PreToolUse', tool_name: 'Read',
90
+ tool_input: { file_path: join(repo, 'nearly-doctor-probe') }, tool_use_id: 'doctor',
91
+ });
92
+ // shell: true because the agent runs these through a shell, and on Windows
93
+ // the installed command is a .cmd that will not spawn any other way.
94
+ const r = spawnSync(cmd, { input: probe, shell: true, encoding: 'utf8', timeout: 30_000 });
95
+ const decided = /permissionDecision|"decision"|"permission"/.test(r.stdout || '');
96
+ if (decided) {
97
+ say(true, 'hooks actually fire', 'the gate answered a test call');
98
+ } else {
99
+ const why = (r.error && r.error.message)
100
+ || (r.stderr || '').trim().split('\n')[0]
101
+ || (r.status !== 0 ? `the hook command exited ${r.status}` : 'the hook ran but answered nothing');
102
+ say(false, 'hooks actually fire', why,
103
+ `the hook command in ${cc.config} does not work here, so nothing is gated and nothing is recorded — check that \`nearly\` runs in a plain shell, then re-run \`nearly\` to rewrite the hooks`);
104
+ }
105
+ }
106
+ }
107
+
64
108
  // 3 — the server, and whether it is this build
65
109
  let health = null;
66
110
  try {
@@ -93,7 +137,11 @@ if (!health) {
93
137
  // them, so this cannot disagree with it.
94
138
  const recDir = paths.recordings();
95
139
  let runs = 0, otherBranches = new Set();
96
- const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
140
+ const real = (p) => {
141
+ let r;
142
+ try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
143
+ return process.platform === 'win32' ? r.toLowerCase() : r; // same place, spelled differently
144
+ };
97
145
  try {
98
146
  for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
99
147
  let created = null;
@@ -362,11 +362,18 @@ export const ADAPTERS = [
362
362
  const cfg = { version: 1, hooks: {} };
363
363
  for (const [their, ours] of Object.entries(this.events)) {
364
364
  const run = cmdFor(ours);
365
- // `command` is the cross-platform fallback; `bash` and `powershell` are
366
- // what the runtime picks per OS. Writing all three means a Windows
367
- // machine finds one whichever property it prefers and Windows is
368
- // exactly where somebody with no other option is running this.
369
- cfg.hooks[their] = [{ type: 'command', command: run, bash: run, powershell: run, timeoutSec: holdFor(ours) }];
365
+ // Two products read this file and disagree about the key. Copilot CLI
366
+ // takes `bash` and `powershell`; VS Code's agent mode takes `windows`,
367
+ // `linux` and `osx` as per-platform overrides and does not document the
368
+ // other two at all. Writing both sets costs a few bytes. Guessing wrong
369
+ // means the hook never runs, and a hook that never runs looks exactly
370
+ // like a week in which nobody did any work.
371
+ cfg.hooks[their] = [{
372
+ type: 'command', command: run,
373
+ bash: run, powershell: run,
374
+ windows: run, linux: run, osx: run,
375
+ timeoutSec: holdFor(ours), timeout: holdFor(ours),
376
+ }];
370
377
  }
371
378
  writeJson(file, cfg); // our own file; nobody else's entries to keep
372
379
  return { file };
package/server/index.mjs CHANGED
@@ -351,7 +351,12 @@ function decide(sid, id, decision, why, scope = 'once') {
351
351
  clearTimeout(p.timer);
352
352
  s.pending.delete(id);
353
353
  if (scope === 'always') rules.set(p.key, decision === 'allow' ? 'log' : 'never');
354
- p.respond(decision, why);
354
+ // One call can arrive down more than one hook — VS Code reads both
355
+ // .claude/settings.local.json and .github/hooks/*.json, so a repo wired for
356
+ // Claude Code and Copilot fires twice for the same tool_use_id. Everyone who
357
+ // asked gets the same answer; answering only the last one left the first hook
358
+ // hanging until the agent's own timeout, which looks like the agent freezing.
359
+ for (const r of p.responders) r(decision, why);
355
360
  record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
356
361
  if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
357
362
  broadcast({ type: 'session-state', session: sid, state: s.state });
@@ -440,7 +445,11 @@ const server = http.createServer(async (req, res) => {
440
445
  // else gets to decide by not answering.
441
446
  const asked = Number(url.searchParams.get('hold')) || 0;
442
447
  const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
443
- const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
448
+ // Same call, second hook: join the question already being asked rather
449
+ // than replacing it, so the person is not asked twice about one thing.
450
+ const already = s.pending.get(id);
451
+ if (already) { already.responders.push(respond); return; }
452
+ const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, responders: [respond] };
444
453
  item.timer = setTimeout(() => decide(sid, id, 'deny',
445
454
  `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
446
455
  s.pending.set(id, item);