nearly-cli 0.1.10 → 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.10",
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 {
@@ -73,15 +117,31 @@ if (!health) {
73
117
  let mine = root;
74
118
  try { mine = realpathSync(root); } catch { /* compare literally */ }
75
119
  const same = health.root === mine;
76
- say(same ? true : false, 'server', same ? `v${health.version}` : `a different install is answering: ${health.root || 'an older build'}`,
77
- 'run `nearly` here it closes an older server that is holding the port');
120
+ let ours = null;
121
+ try { ours = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { /* unknown */ }
122
+ if (same) {
123
+ say(true, 'server', `v${health.version}`);
124
+ } else if (health.version && health.version === ours) {
125
+ // Running this through npx gives a throwaway directory every time, so the
126
+ // paths differ even when the build is identical. Saying "a different
127
+ // install" there is true and useless; the version is what anyone cares
128
+ // about, and a matching one is holding nothing back.
129
+ say(null, 'server', `v${health.version} from another copy of the same version — nothing stale about it`);
130
+ } else {
131
+ say(false, 'server', `an older build is answering${health.version ? ` (v${health.version})` : ''}: ${health.root || 'it does not say where it lives'}`,
132
+ 'run `nearly` here — it closes the older server holding the port');
133
+ }
78
134
  }
79
135
 
80
136
  // 4 — recordings for this branch, matched the way the record builder matches
81
137
  // them, so this cannot disagree with it.
82
138
  const recDir = paths.recordings();
83
139
  let runs = 0, otherBranches = new Set();
84
- 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
+ };
85
145
  try {
86
146
  for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
87
147
  let created = null;
@@ -49,7 +49,12 @@ lines.push(sb.runs > 1
49
49
  lines.push('');
50
50
  lines.push(urlBase
51
51
  ? `**[Watch the record (${mmss(sb.totalS)})](${urlBase}/${slug}.html)** · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`
52
- : `Record: \`ui/records/${slug}.html\` in the Nearly checkout (${mmss(sb.totalS)}, not hosted yet) · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`);
52
+ // No host configured. Everything a reviewer needs to act on is in this
53
+ // comment already — what was refused, and what the diff therefore cannot show
54
+ // them. Only the player is missing, so say where it is honestly rather than
55
+ // naming a path from the developer's own checkout that means nothing to
56
+ // anybody who installed this.
57
+ : `${sb.runs > 1 ? `${sb.runs} agent sessions` : `Agent \`${sb.name}\``} · ${sb.model} · ${sb.date} · ${mmss(sb.totalS)} recording, kept on the author's machine`);
53
58
  lines.push('');
54
59
  if (outcome?.notDone?.length) {
55
60
  lines.push(`> **${outcome.notDone.length} thing${outcome.notDone.length > 1 ? 's' : ''} the agent wanted to do did not happen.** The diff cannot show you this.`);
@@ -67,7 +72,7 @@ sb.scenes.forEach((s, i) => { lines.push(`${i + 1}. **${s.kind}** — ${s.narrat
67
72
  lines.push('');
68
73
  lines.push('</details>');
69
74
  lines.push('');
70
- lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}</sub>`);
75
+ lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}${urlBase ? '' : ' The narrated version is not published anywhere; `nearly publish` puts it on GitHub Pages.'}</sub>`);
71
76
  // A hidden marker so we can find our own comment again on the next push and
72
77
  // edit it, instead of stacking a new one on every push until nobody reads any.
73
78
  // Deliberately carries no product name. This string is how a comment is
@@ -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);