nearly-cli 0.1.17 → 0.1.18

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/README.md CHANGED
@@ -146,8 +146,10 @@ npx nearly-cli
146
146
 
147
147
  · Claude Code sessions here are gated and recorded (run end to end against a live agent)
148
148
  · Cursor sessions here are gated and recorded (built to their published hook spec, not yet run against a live agent)
149
+ · Claude Code sessions opened in another folder are gated too, once they work in this repo
150
+ · restart any Claude Code or Cursor session already open — hooks are read when a session starts
149
151
  · upgrades reach this repo automatically
150
- · the record is offered when you push
152
+ · the record is added to #12 on your next push — sessions from now on
151
153
 
152
154
  Now just work. Requests that need you appear at http://127.0.0.1:47653
153
155
  Nothing to leave running. Turn it off again with --off.
@@ -159,6 +161,25 @@ Nothing to configure, no server to start, and `nearly off` removes all of it.
159
161
  Run it again in any other repo you want recorded. After the first time the
160
162
  command is just `nearly`.
161
163
 
164
+ Three things that are easy to miss, and that the command now says out loud:
165
+
166
+ - **Only sessions from now on.** A session already open keeps the hooks it
167
+ started with, which here means none. Restart it.
168
+ - **The record reaches a pull request on a push.** Turning Nearly on for a branch
169
+ whose pull request already exists changes nothing on that pull request until
170
+ you push again. If that pull request is already merged, open a new one.
171
+ - **Sessions opened somewhere else are covered.** Claude Code only reads a repo's
172
+ hooks for a session started in that repo, so a session opened one folder up —
173
+ a parent folder, a monorepo root, your home directory — used to edit the repo
174
+ with nothing gated and nothing recorded, while `nearly doctor` said all was
175
+ well. `nearly` now also adds one hook to Claude Code's user settings
176
+ (`~/.claude/settings.json`). It stays silent unless a call works in a repo
177
+ Nearly is on for; from then on that session is gated like one started there,
178
+ including calls that never mention the repo. Sessions that have nothing to do
179
+ with it pay a process start per tool call and never reach a server. It is
180
+ removed when you turn Nearly off for the last repo, and `nearly --local-only`
181
+ skips it.
182
+
162
183
  ### Where it installs
163
184
 
164
185
  `npx nearly-cli` installs a copy into `~/.nearly/runtime`, and the hooks call
@@ -472,6 +493,7 @@ The claim this project makes is testable: a reviewer who sees the session record
472
493
  - `server/index.mjs`, spawn sessions, hooks, policy, recorder, undo
473
494
  - `ui/index.html`, sessions, triage of pending approvals, rules, log
474
495
  - `scripts/attach.mjs`, install or remove the hooks in a repo of your own; `scripts/post-recap.mjs`, comment the recap on its PR
496
+ - `scripts/outside.mjs`, the hook in Claude Code's user settings that gates sessions opened in another folder
475
497
  - `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
476
498
  - `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
477
499
  - `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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": {
@@ -34,6 +34,6 @@
34
34
  },
35
35
  "homepage": "https://anujpatel06.github.io/nearly/",
36
36
  "scripts": {
37
- "test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs test/runtime.test.mjs"
37
+ "test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs test/outside.test.mjs test/runtime.test.mjs"
38
38
  }
39
39
  }
@@ -13,6 +13,8 @@
13
13
  // · a git pre-push hook, so the record is offered when the work leaves your
14
14
  // machine
15
15
  // · where the records are published, read from the Nearly's own remote
16
+ // · one hook in Claude Code's user settings, so a session opened in another
17
+ // folder is gated when it works in this repo (--local-only to skip it)
16
18
  //
17
19
  // There is no server to remember. The hooks start it the first time they need
18
20
  // it, and if it cannot start, Claude Code falls back to its own prompts and
@@ -26,6 +28,8 @@ import { dataRoot, paths } from '../server/paths.mjs';
26
28
  import { choose, installed as agentsOnMachine } from './detect.mjs';
27
29
  import { installRuntime, hasRuntime, isRuntime, runtimeCommand } from './runtime.mjs';
28
30
  import { ADAPTERS } from '../server/adapters.mjs';
31
+ import { installOutside, removeOutside, attachedRepos, userSettingsFile } from './outside.mjs';
32
+ import { prForBranch } from './pr-state.mjs';
29
33
 
30
34
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
31
35
  const HOOK = join(root, 'scripts', 'hook.mjs');
@@ -146,6 +150,7 @@ const off = argv.includes('--off') || argv.includes('--detach');
146
150
  // Now the never-rules block with nobody present, everything else is done and
147
151
  // recorded, and holding for approval is something you turn on while watching.
148
152
  const supervise = argv.includes('--supervise');
153
+ const localOnly = argv.includes('--local-only');
149
154
  const auto = !supervise;
150
155
  // The repo is wherever git says its top is. Looking only for a .git folder in
151
156
  // the current directory rejected every subfolder, which is where people usually
@@ -259,6 +264,24 @@ try {
259
264
  notes.push(`could not remember this repo for the dashboard: ${e.message}`);
260
265
  }
261
266
 
267
+ // ---------------------------------------------------------------------------
268
+ // Sessions opened in another folder
269
+ // ---------------------------------------------------------------------------
270
+ // Claude Code only reads this repo's hooks when a session starts here. The hook
271
+ // in its user settings covers the rest, and stays only while some repo still
272
+ // has Nearly on. Never from a pinned npx call: that would put a registry lookup
273
+ // in front of every tool call in every session on the machine.
274
+ let reach = null;
275
+ const claudeWired = wired.some((a) => a.id === 'claude-code');
276
+ if (!off && claudeWired && !localOnly && (installed || runtime || !fromPackage)) {
277
+ try {
278
+ reach = installOutside((ev) => hookCmd(ev));
279
+ if (reach.error) { notes.push(`sessions opened in other folders are not covered: ${reach.error}`); reach = null; }
280
+ } catch (e) { notes.push(`sessions opened in other folders are not covered: ${e.message}`); }
281
+ } else if (off || localOnly) {
282
+ try { if (!attachedRepos().length) removeOutside(); } catch { /* leave it: it answers nothing without repos */ }
283
+ }
284
+
262
285
  // ---------------------------------------------------------------------------
263
286
  // A server from somewhere else, already holding the port
264
287
  // ---------------------------------------------------------------------------
@@ -347,6 +370,7 @@ if (off) {
347
370
  ? ` hooks removed: ${wired.map((a) => a.name).join(', ')}`
348
371
  : ' no agent hooks of ours were installed');
349
372
  console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
373
+ console.log(dim(' restart any agent session open here; it keeps the hooks it started with'));
350
374
  console.log('');
351
375
  process.exit(0);
352
376
  }
@@ -364,9 +388,30 @@ for (const a of wired) {
364
388
  const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
365
389
  console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
366
390
  }
391
+ if (reach) {
392
+ console.log(` ${ok('·')} Claude Code sessions opened in another folder are gated too, once they work in this repo`);
393
+ console.log(` ${dim(`a hook in ${userSettingsFile().replace(process.env.HOME || '~', '~')}; it stays silent everywhere else`)}`);
394
+ } else if (claudeWired && !off) {
395
+ console.log(` ${ok('·')} ${dim('only Claude Code sessions started in this folder are gated')}`);
396
+ }
397
+ // Hooks are read when a session starts. Someone who turns this on and carries on
398
+ // in the window they already had is not gated at all, and nothing says so.
399
+ if (wired.length) {
400
+ console.log(` ${bold('·')} ${bold(`restart any ${wired.map((a) => a.name).join(' or ')} session already open`)} ${dim('— hooks are read when a session starts')}`);
401
+ }
367
402
  console.log(` ${ok('·')} ${dim(updateNote())}`);
368
403
  if (push.status === 0) {
369
- console.log(` ${ok('·')} the record is offered when you push`);
404
+ // Only sessions from now on are recorded, and the record reaches a pull
405
+ // request on a push. Said here, because the natural thing is to look at a pull
406
+ // request that already exists and wonder where the record is.
407
+ const pr = prForBranch(repo);
408
+ if (pr.state === 'open') {
409
+ console.log(` ${ok('·')} the record is added to #${pr.number} on your next push ${dim('— sessions from now on')}`);
410
+ } else if (pr.state === 'merged' || pr.state === 'closed') {
411
+ console.log(` ${ok('·')} the record is offered on your next push ${dim(`— #${pr.number} for this branch is ${pr.state}, so open a new pull request first`)}`);
412
+ } else {
413
+ console.log(` ${ok('·')} the record is offered on your next push ${dim('— sessions from now on, once a pull request is open')}`);
414
+ }
370
415
  } else {
371
416
  // All of it: when a pre-push hook of yours is already there, the lines after
372
417
  // the first are the ones that say how to add Nearly to it by hand.
@@ -17,6 +17,8 @@ import { fileURLToPath } from 'node:url';
17
17
  import { execFileSync, spawnSync } from 'node:child_process';
18
18
  import { paths, dataRoot } from '../server/paths.mjs';
19
19
  import { ADAPTERS, OURS_RE } from '../server/adapters.mjs';
20
+ import { outsideInstalled } from './outside.mjs';
21
+ import { prForBranch } from './pr-state.mjs';
20
22
 
21
23
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
22
24
  const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
@@ -122,6 +124,13 @@ if (gated.length) {
122
124
  }
123
125
  }
124
126
 
127
+ // A repo's own hooks only run for sessions started in it. Everything above can
128
+ // be green while every session is opened one folder up and none of it is seen.
129
+ if (gated.some((a) => a.id === 'claude-code')) {
130
+ if (outsideInstalled()) say(true, 'sessions opened in other folders', 'gated once they work in this repo');
131
+ else say(null, 'sessions opened in other folders', 'not gated — only Claude Code sessions started in this folder are. Run `nearly` here to cover them');
132
+ }
133
+
125
134
  // 3 — the server, and whether it is this build
126
135
  let health = null;
127
136
  try {
@@ -207,14 +216,11 @@ if (!ghOk) {
207
216
  const auth = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8' }).status === 0;
208
217
  say(auth, 'GitHub CLI (gh)', auth ? 'installed and signed in' : 'installed but not signed in', 'run `gh auth login`');
209
218
  if (auth) {
210
- const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
211
- if (pr.status === 0) {
212
- let url = '';
213
- try { url = JSON.parse(pr.stdout).url; } catch { /* keep it blank */ }
214
- say(true, 'open pull request', url);
215
- } else {
216
- say(null, 'open pull request', `none for ${branch} — raise one, then push again`);
217
- }
219
+ const pr = prForBranch(repo);
220
+ if (pr.state === 'open') say(true, 'open pull request', pr.url);
221
+ else if (pr.state === 'merged' || pr.state === 'closed') {
222
+ say(null, 'open pull request', `none — #${pr.number} for ${branch} is ${pr.state}. Open a new one, then push again`);
223
+ } else say(null, 'open pull request', `none for ${branch} — raise one, then push again`);
218
224
  }
219
225
  }
220
226
 
package/scripts/hook.mjs CHANGED
@@ -37,7 +37,8 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
37
37
  const args = process.argv.slice(2);
38
38
  const flag = args.find((a) => a.startsWith('--adapter='));
39
39
  const positional = args.filter((a) => !a.startsWith('--'));
40
- const [event, name = 'repo'] = positional;
40
+ const [event] = positional;
41
+ let name = positional[1] || 'repo';
41
42
  if (!event) process.exit(0);
42
43
 
43
44
  // An unknown id is a typo in a config file, not a reason to wedge the agent.
@@ -45,7 +46,9 @@ const adapter = flag ? byId(flag.slice('--adapter='.length)) : null;
45
46
  // Nobody is at the keyboard. Set by attach --auto, carried per repo rather than
46
47
  // as machine-wide state, because supervising one project and not another is the
47
48
  // normal case.
48
- const unattended = args.includes('--auto');
49
+ let unattended = args.includes('--auto');
50
+ // Written into Claude Code's user settings rather than a repo's: see outside.mjs.
51
+ const outside = args.includes('--outside');
49
52
 
50
53
  const body = await new Promise((r) => {
51
54
  let s = '';
@@ -87,6 +90,28 @@ async function start() {
87
90
  return false;
88
91
  }
89
92
 
93
+ // A session opened somewhere else. Decide whether this call is any of our
94
+ // business before doing anything that costs more than reading a few small files.
95
+ // Nothing goes to a server unless the answer is yes.
96
+ let extra = '';
97
+ if (outside) {
98
+ let hook = {};
99
+ try { hook = JSON.parse(body || '{}'); } catch { /* nothing to go on */ }
100
+ const { attachedRepos, concerns, launchedIn, rememberedRepo } = await import('./outside.mjs');
101
+ const repos = attachedRepos();
102
+ if (!repos.length) process.exit(0);
103
+ // Started in one of them: its own hooks are running, and answering twice would
104
+ // record every call twice. A subfolder is not certain to load them, so there
105
+ // both fire and the server keeps whichever arrives first.
106
+ if (repos.some((r) => r.real === launchedIn(hook))) process.exit(0);
107
+ const hit = ((event === 'pre-tool' || event === 'post-tool') ? concerns(hook, repos) : null)
108
+ || rememberedRepo(hook.session_id, repos);
109
+ if (!hit) process.exit(0);
110
+ name = hit.name;
111
+ unattended = hit.auto;
112
+ extra = `&outside=1&repo=${encodeURIComponent(hit.repo)}`;
113
+ }
114
+
90
115
  let health = await up();
91
116
  if (health === 'stale') {
92
117
  // Take the port back rather than run whatever is already there. Nobody reads
@@ -118,7 +143,7 @@ if (adapter && adapter.normalize) {
118
143
  try {
119
144
  const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
120
145
  const auto = unattended ? '&auto=1' : '';
121
- const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}${auto}`, {
146
+ const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}${auto}${extra}`, {
122
147
  method: 'POST',
123
148
  headers: { 'content-type': 'application/json' },
124
149
  body: payload,
@@ -0,0 +1,173 @@
1
+ // Sessions opened somewhere else.
2
+ //
3
+ // Claude Code reads a repo's .claude/settings.local.json only when a session is
4
+ // started in that repo. Open it one folder up — a parent folder, a monorepo
5
+ // root, your home directory — and edit the repo from there, and the repo's hooks
6
+ // never run. Nothing was recorded, nothing was refused, and `nearly doctor`,
7
+ // which checks the repo, said everything was fine. That was found by the person
8
+ // who built this, which is how sure we can be that everyone else will hit it.
9
+ //
10
+ // So attach also writes one hook into Claude Code's user settings, which every
11
+ // session reads. It is silent unless a call touches a repo Nearly is on for:
12
+ //
13
+ // - a session started in such a repo already has that repo's own hooks, so
14
+ // this one steps aside;
15
+ // - a call whose working directory or file is inside such a repo is gated and
16
+ // recorded as that repo's, with that repo's own settings (--auto or not);
17
+ // - once a session has been gated this way, the rest of its calls are too —
18
+ // `rm -rf ~` does not mention the repo, and a gate that only looked at the
19
+ // repo's own paths would wave it through;
20
+ // - anything else answers nothing and costs a process start.
21
+ //
22
+ // The repo's own hook config stays the source of truth. The user-level hook
23
+ // holds no list of its own; it reads which repos are on, and how, from the
24
+ // repos themselves.
25
+
26
+ import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync } from 'node:fs';
27
+ import { join, dirname, resolve, sep, isAbsolute } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { paths } from '../server/paths.mjs';
30
+ import { OURS_RE, isOurs } from '../server/adapters.mjs';
31
+
32
+ // Claude Code's user settings. CLAUDE_CONFIG_DIR moves them, and is also how the
33
+ // tests keep this away from the real file.
34
+ export const userSettingsFile = () =>
35
+ join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
36
+
37
+ // The events worth hearing from a session opened elsewhere. SessionStart says
38
+ // nothing about where the session will work, so it is left out.
39
+ export const OUTSIDE_EVENTS = {
40
+ UserPromptSubmit: 'prompt', PreToolUse: 'pre-tool', PostToolUse: 'post-tool',
41
+ Stop: 'stop', SessionEnd: 'session-end',
42
+ };
43
+
44
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
45
+
46
+ // The real path of p, or of its nearest existing parent with the rest appended:
47
+ // a file the agent is about to create does not exist yet, and still belongs to
48
+ // the repo it is being created in.
49
+ export function realish(p) {
50
+ if (!p) return null;
51
+ let abs = resolve(String(p).replace(/^~(?=$|[\\/])/, homedir()));
52
+ const tail = [];
53
+ for (;;) {
54
+ try { return fold(join(realpathSync.native(abs), ...tail.reverse())); }
55
+ catch {
56
+ const up = dirname(abs);
57
+ if (up === abs) return fold(resolve(p));
58
+ tail.push(abs.slice(up.length).replace(/^[\\/]/, ''));
59
+ abs = up;
60
+ }
61
+ }
62
+ }
63
+
64
+ export const inside = (child, parent) =>
65
+ !!child && !!parent && (child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep));
66
+
67
+ // The repos Nearly is on for with Claude Code, and how each was turned on. A repo
68
+ // in the list whose hooks have since been removed by hand is not one.
69
+ export function attachedRepos() {
70
+ let list = [];
71
+ try { list = JSON.parse(readFileSync(paths.repos(), 'utf8')); } catch { return []; }
72
+ const out = [];
73
+ for (const repo of Array.isArray(list) ? list : []) {
74
+ let cmd = null;
75
+ try {
76
+ const s = JSON.parse(readFileSync(join(repo, '.claude', 'settings.local.json'), 'utf8').replace(/^\uFEFF/, ''));
77
+ cmd = (s.hooks?.PreToolUse || []).flatMap((e) => e.hooks || []).map((h) => h.command)
78
+ .find((c) => typeof c === 'string' && OURS_RE.test(c));
79
+ } catch { continue; }
80
+ if (!cmd) continue;
81
+ const name = (cmd.match(/\bpre-tool\s+([a-z0-9-]+)/i) || [])[1] || 'repo';
82
+ out.push({ repo, real: realish(repo), name, auto: /\s--auto\b/.test(cmd) });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // Every path a call names: where it runs, the file it reads or writes, and any
88
+ // absolute or home-relative path in a shell command.
89
+ function pathsOf(hook) {
90
+ const found = [];
91
+ const i = hook.tool_input || {};
92
+ for (const k of ['file_path', 'path', 'notebook_path']) if (typeof i[k] === 'string') found.push(isAbsolute(i[k]) ? i[k] : resolve(hook.cwd || '.', i[k]));
93
+ if (hook.cwd) found.push(hook.cwd);
94
+ if (typeof i.command === 'string') {
95
+ // Rough on purpose. Reading a path that is not one costs a gated call at
96
+ // worst; missing one that is lets the call through ungated.
97
+ const words = i.command.split(/[\s;|&<>()]+/).map((w) => w.replace(/^[^\w~./\\:-]+|["']+$/g, '').replace(/^["']+/, '')).filter(Boolean);
98
+ words.forEach((w, n) => {
99
+ const afterCd = /^(?:cd|pushd)$/.test(words[n - 1] || '');
100
+ if (/^(?:~|\/|[A-Za-z]:[\\/])/.test(w)) found.push(w);
101
+ else if (afterCd || /[\\/]/.test(w)) found.push(resolve(hook.cwd || '.', w));
102
+ });
103
+ }
104
+ return found;
105
+ }
106
+
107
+ // The attached repo this call touches, if any.
108
+ export function concerns(hook, repos) {
109
+ const touched = pathsOf(hook).map(realish);
110
+ return repos.find((r) => touched.some((p) => inside(p, r.real))) || null;
111
+ }
112
+
113
+ // Sessions already gated this way are remembered by the server; see marks.mjs.
114
+ export { rememberOutside, rememberedRepo } from '../server/marks.mjs';
115
+
116
+ // Where the session was started. Claude Code tells hooks outright; the payload's
117
+ // cwd is the fallback, and is the same place until the agent changes directory.
118
+ export const launchedIn = (hook) => realish(process.env.CLAUDE_PROJECT_DIR || hook.cwd);
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Writing and removing the user-level hook
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function readSettings(file) {
125
+ if (!existsSync(file)) return null;
126
+ try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); }
127
+ catch { return undefined; } // present but unreadable: never overwrite it
128
+ }
129
+
130
+ const outsideEntry = (e) => isOurs(e) && /--outside\b/.test(JSON.stringify(e));
131
+
132
+ // cmdFor(event) is the same command the repo hooks run, without a repo name.
133
+ export function installOutside(cmdFor) {
134
+ const file = userSettingsFile();
135
+ const s = readSettings(file);
136
+ if (s === undefined) return { error: `${file} could not be read, so it was left alone` };
137
+ const settings = s || {};
138
+ const hooks = settings.hooks || {};
139
+ for (const ev of Object.keys(hooks)) {
140
+ const kept = (hooks[ev] || []).filter((e) => !outsideEntry(e));
141
+ if (kept.length) hooks[ev] = kept; else delete hooks[ev];
142
+ }
143
+ for (const [their, ours] of Object.entries(OUTSIDE_EVENTS)) {
144
+ hooks[their] = [...(hooks[their] || []),
145
+ { hooks: [{ type: 'command', command: `${cmdFor(ours)} --outside`, timeout: ours === 'pre-tool' ? 600 : ours === 'session-end' ? 120 : 30 }] }];
146
+ }
147
+ settings.hooks = hooks;
148
+ mkdirSync(dirname(file), { recursive: true });
149
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
150
+ return { file };
151
+ }
152
+
153
+ export function removeOutside() {
154
+ const file = userSettingsFile();
155
+ const settings = readSettings(file);
156
+ if (!settings || !settings.hooks) return { removed: false };
157
+ let removed = false;
158
+ for (const ev of Object.keys(settings.hooks)) {
159
+ const before = settings.hooks[ev] || [];
160
+ const kept = before.filter((e) => !outsideEntry(e));
161
+ if (kept.length !== before.length) removed = true;
162
+ if (kept.length) settings.hooks[ev] = kept; else delete settings.hooks[ev];
163
+ }
164
+ if (!removed) return { removed: false };
165
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
166
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
167
+ return { removed: true, file };
168
+ }
169
+
170
+ export function outsideInstalled() {
171
+ const s = readSettings(userSettingsFile());
172
+ return !!(s && s.hooks && Object.values(s.hooks).some((list) => (list || []).some(outsideEntry)));
173
+ }
@@ -0,0 +1,20 @@
1
+ // The pull request for the branch you are on, and whether it can still take a record.
2
+ //
3
+ // `gh pr view` finds a branch's pull request whatever state it is in. Everything
4
+ // here used to read "found" as "open", so doctor reported a merged pull request
5
+ // as the open one, and a push offered to post a record onto a conversation that
6
+ // had already ended — where nobody reviewing will ever see it.
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+
10
+ export function prForBranch(repo) {
11
+ if (spawnSync('gh', ['--version'], { encoding: 'utf8' }).status !== 0) return { state: 'no-gh' };
12
+ const r = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state'], { cwd: repo, encoding: 'utf8', timeout: 15_000 });
13
+ if (r.status !== 0) {
14
+ return /not logged|authentication|gh auth/i.test(r.stderr || '') ? { state: 'signed-out' } : { state: 'none' };
15
+ }
16
+ try {
17
+ const { number, url, state } = JSON.parse(r.stdout);
18
+ return { state: String(state || 'OPEN').toLowerCase(), number, url };
19
+ } catch { return { state: 'none' }; }
20
+ }
@@ -108,16 +108,20 @@ if (!hasGh) {
108
108
  console.log('');
109
109
  process.exit(0);
110
110
  }
111
- const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
112
- if (pr.status !== 0) {
113
- const why = /not logged|authentication|gh auth/i.test(pr.stderr || '')
111
+ const { prForBranch } = await import('./pr-state.mjs');
112
+ const pr = prForBranch(repo);
113
+ if (pr.state !== 'open') {
114
+ const why = pr.state === 'signed-out'
114
115
  ? 'gh is installed but not signed in. Run `gh auth login`, then push again.'
115
- : 'No open pull request for this branch yet. Raise one, then push again to attach the record.';
116
+ : pr.state === 'merged' || pr.state === 'closed'
117
+ // Posting onto a finished conversation reaches nobody who is reviewing.
118
+ ? `The pull request for this branch, #${pr.number}, is already ${pr.state}. Open a new one, then push again to attach the record.`
119
+ : 'No open pull request for this branch yet. Raise one, then push again to attach the record.';
116
120
  console.log(dim(` ${why}`));
117
121
  console.log('');
118
122
  process.exit(0);
119
123
  }
120
- const prUrl = (() => { try { return JSON.parse(pr.stdout).url; } catch { return null; } })();
124
+ const prUrl = pr.url || null;
121
125
 
122
126
  if (process.env.NEARLY_NO_TTY === '1' || !process.stdin.isTTY) {
123
127
  console.log(dim(' No terminal to ask on, so nothing was posted.'));
package/server/index.mjs CHANGED
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { DEFAULT_TIER, ruleKey, classify as classifyWith } from './policy.mjs';
14
14
  import { paths } from './paths.mjs';
15
+ import { rememberOutside } from './marks.mjs';
15
16
 
16
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const PORT = Number(process.env.NEARLY_PORT || 47653);
@@ -242,6 +243,24 @@ function modelFromTranscript(p) {
242
243
  return null;
243
244
  }
244
245
 
246
+ // The most recent thing the person typed, from a Claude Code transcript.
247
+ function promptFromTranscript(p) {
248
+ if (!p || !fs.existsSync(p)) return null;
249
+ try {
250
+ let last = null;
251
+ for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
252
+ if (!l) continue;
253
+ let j; try { j = JSON.parse(l); } catch { continue; }
254
+ if (j.type !== 'user' || j.isMeta) continue;
255
+ const c = j.message?.content;
256
+ const text = typeof c === 'string' ? c
257
+ : Array.isArray(c) && !c.some((x) => x?.type === 'tool_result') ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
258
+ if (text && !/^<(?:command-|local-command|system-reminder)/.test(text.trim())) last = text;
259
+ }
260
+ return last;
261
+ } catch { return null; }
262
+ }
263
+
245
264
  function turnDiff(cwd, base, maxLines = 200) {
246
265
  const from = base || 'HEAD';
247
266
  const numstat = git(cwd, ['diff', '--numstat', from]);
@@ -357,6 +376,8 @@ function decide(sid, id, decision, why, scope = 'once') {
357
376
  // asked gets the same answer; answering only the last one left the first hook
358
377
  // hanging until the agent's own timeout, which looks like the agent freezing.
359
378
  for (const r of p.responders) r(decision, why);
379
+ s.answered ||= new Map();
380
+ s.answered.set(id, [decision, why]);
360
381
  record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
361
382
  if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
362
383
  broadcast({ type: 'session-state', session: sid, state: s.state });
@@ -389,10 +410,30 @@ const server = http.createServer(async (req, res) => {
389
410
  try { hook = JSON.parse(await readBody(req) || '{}'); } catch { /* keep {} */ }
390
411
  const ev = url.pathname.slice('/hooks/'.length);
391
412
  const attach = url.searchParams.get('attach');
413
+ // From the hook in Claude Code's user settings: a session started outside the
414
+ // repo. With a repo it touched that repo; without one, it is ours only if an
415
+ // earlier call already made it so, and otherwise gets no answer at all.
416
+ const outside = url.searchParams.get('outside') === '1';
417
+ const outsideRepo = outside ? url.searchParams.get('repo') : null;
418
+ if (outside && !attach && !(hook.session_id && sessions.has(hook.session_id))) return hookOk(res);
392
419
  let sidResolved = sidParam;
393
- if (!sidResolved && attach && hook.session_id) {
420
+ if (!sidResolved && (attach || outside) && hook.session_id) {
394
421
  sidResolved = hook.session_id;
395
- if (!sessions.has(sidResolved)) attachSession({ id: sidResolved, name: attach.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo', cwd: hook.cwd || process.cwd() });
422
+ if (!sessions.has(sidResolved) && attach) {
423
+ const made = attachSession({ id: sidResolved, name: attach.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo', cwd: outsideRepo || hook.cwd || process.cwd() });
424
+ // Its prompt went by before anything said the session was ours. The
425
+ // transcript still has it, and a record that starts mid-task without
426
+ // saying what was asked is missing the part a reviewer reads first.
427
+ if (outsideRepo) {
428
+ made.outside = true;
429
+ try { rememberOutside(sidResolved, outsideRepo); } catch { /* it is re-found by path next time */ }
430
+ // Later calls from this session may not mention the repo, and arrive
431
+ // without its settings; they keep the ones it was gated with.
432
+ made.auto = url.searchParams.get('auto') === '1';
433
+ const asked = promptFromTranscript(hook.transcript_path);
434
+ if (asked) { made.lastPrompt = { text: asked, at: Date.now() }; record(sidResolved, { type: 'prompt', text: asked.slice(0, 4000) }); }
435
+ }
436
+ }
396
437
  }
397
438
  const s = sessions.get(sidResolved);
398
439
  const sid = sidResolved;
@@ -410,7 +451,10 @@ const server = http.createServer(async (req, res) => {
410
451
  return hookOk(res);
411
452
  }
412
453
  if (ev === 'prompt') {
413
- if (s) { s.state = 'working'; record(sid, { type: 'prompt', text: String(hook.prompt || '').slice(0, 4000) }); broadcast({ type: 'session-state', session: sid, state: s.state }); }
454
+ // The same prompt twice within a few seconds is one prompt heard by two hooks.
455
+ const text = String(hook.prompt || '');
456
+ const echo = s && s.lastPrompt && s.lastPrompt.text === text && Date.now() - s.lastPrompt.at < 5000;
457
+ if (s && !echo) { s.lastPrompt = { text, at: Date.now() }; s.state = 'working'; record(sid, { type: 'prompt', text: text.slice(0, 4000) }); broadcast({ type: 'session-state', session: sid, state: s.state }); }
414
458
  return hookOk(res);
415
459
  }
416
460
  if (ev === 'session-end') {
@@ -432,7 +476,7 @@ const server = http.createServer(async (req, res) => {
432
476
  // stalls the run and teaches people to turn the gate off. The never-rules
433
477
  // still bite, because those never needed a person. Everything that would
434
478
  // have been asked is done and written down instead.
435
- const unattended = url.searchParams.get('auto') === '1';
479
+ const unattended = url.searchParams.get('auto') === '1' || (outside && !!s?.auto);
436
480
  let { tier, reason } = classifyWith(hook, rules);
437
481
  if (unattended && tier === 'ask') { tier = 'log'; reason = 'allowed unattended — nobody was asked'; }
438
482
  const id = hook.tool_use_id || randomUUID();
@@ -444,10 +488,22 @@ const server = http.createServer(async (req, res) => {
444
488
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
445
489
  });
446
490
  if (!s) return respond('deny', 'unknown session');
447
- if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier, unattended }); return respond('deny', `never (${reason})`); }
491
+ // Already answered: the same call through a second hook a repo's own and
492
+ // the user-level one, or VS Code reading two config files. Same answer,
493
+ // written down once.
494
+ if (hook.tool_use_id && s.answered?.has(id)) return respond(...s.answered.get(id));
495
+ const answer = (decision, why) => {
496
+ if (hook.tool_use_id) {
497
+ s.answered ||= new Map();
498
+ s.answered.set(id, [decision, why]);
499
+ if (s.answered.size > 500) s.answered.delete(s.answered.keys().next().value);
500
+ }
501
+ return respond(decision, why);
502
+ };
503
+ if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier, unattended }); return answer('deny', `never (${reason})`); }
448
504
  if (tier === 'log') {
449
505
  record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: unattended ? 'auto' : 'policy', tool: shown, input: hook.tool_input, tier });
450
- return respond('allow', unattended ? reason : `do and log (${reason})`);
506
+ return answer('allow', unattended ? reason : `do and log (${reason})`);
451
507
  }
452
508
  // ask: hold the response until the UI decides, or fail closed
453
509
  // A harness may say it will not wait as long as we would. It can shorten
@@ -470,10 +526,15 @@ const server = http.createServer(async (req, res) => {
470
526
  }
471
527
 
472
528
  if (ev === 'post-tool') {
473
- if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_label || hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
529
+ const seen = s && hook.tool_use_id && s.posted?.has(hook.tool_use_id);
530
+ if (s && hook.tool_use_id) { s.posted ||= new Set(); s.posted.add(hook.tool_use_id); if (s.posted.size > 500) s.posted.delete(s.posted.values().next().value); }
531
+ if (s && !seen) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_label || hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
474
532
  return hookOk(res);
475
533
  }
476
534
  if (ev === 'stop') {
535
+ // One turn ending, heard by two hooks, is still one turn.
536
+ if (s && s.attached && s.lastStopAt && Date.now() - s.lastStopAt < 3000) return hookOk(res);
537
+ if (s && s.attached) s.lastStopAt = Date.now();
477
538
  if (s && s.attached) {
478
539
  s.turns += 1;
479
540
  s.state = 'idle';
@@ -0,0 +1,40 @@
1
+ // Claude Code sessions opened outside a repo, remembered once they have been
2
+ // gated in it. Written by the server, read by the hook (scripts/outside.mjs).
3
+
4
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { paths } from './paths.mjs';
7
+
8
+ // One small file per session, naming the repo.
9
+ //
10
+ // Later calls from such a session may not mention the repo at all, and still
11
+ // have to be gated. Asking the server would put a network round trip in front of
12
+ // every tool call of every Claude Code session on the machine, and asking one
13
+ // from an older build got every one of those calls refused as an unknown
14
+ // session. A file lookup costs neither.
15
+ const safeId = (sid) => String(sid || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
16
+ const WEEK = 7 * 24 * 60 * 60 * 1000;
17
+
18
+ export function rememberOutside(sid, repo) {
19
+ const id = safeId(sid);
20
+ if (!id) return;
21
+ const dir = paths.outside();
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(join(dir, id), String(repo));
24
+ try {
25
+ for (const f of readdirSync(dir)) {
26
+ const full = join(dir, f);
27
+ if (Date.now() - statSync(full).mtimeMs > WEEK) rmSync(full, { force: true });
28
+ }
29
+ } catch { /* tidying is optional */ }
30
+ }
31
+
32
+ export function rememberedRepo(sid, repos) {
33
+ const id = safeId(sid);
34
+ if (!id) return null;
35
+ try {
36
+ const repo = readFileSync(join(paths.outside(), id), 'utf8').trim();
37
+ return repos.find((r) => r.repo === repo) || null;
38
+ } catch { return null; }
39
+ }
40
+
package/server/paths.mjs CHANGED
@@ -60,4 +60,7 @@ export const paths = {
60
60
  // with no link. Same lesson as recordings — anything a person configured
61
61
  // belongs in their space, not in ours.
62
62
  config: () => dataFile('NEARLY_CONFIG', 'config.json'),
63
+ // Claude Code sessions opened outside a repo that have been gated in it, so the
64
+ // hook can keep gating them without asking the server. See scripts/outside.mjs.
65
+ outside: () => process.env.NEARLY_OUTSIDE || join(dataRoot, 'outside'),
63
66
  };