nearly-cli 0.1.17 → 0.1.20
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 +36 -7
- package/package.json +2 -2
- package/scripts/attach.mjs +54 -2
- package/scripts/build-recap.mjs +28 -6
- package/scripts/doctor.mjs +14 -8
- package/scripts/hook.mjs +28 -3
- package/scripts/outside.mjs +173 -0
- package/scripts/posting.mjs +21 -0
- package/scripts/pr-state.mjs +20 -0
- package/scripts/push-record.mjs +19 -21
- package/server/index.mjs +91 -7
- package/server/marks.mjs +40 -0
- package/server/paths.mjs +3 -0
- package/server/policy.mjs +18 -4
- package/ui/recap.template.html +1 -1
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
|
|
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
|
|
@@ -205,7 +226,7 @@ Nothing about how you work changes. Open the repo in VS Code or a terminal, star
|
|
|
205
226
|
1. **Reads run silently.** Anything that only looks at your code is allowed and logged.
|
|
206
227
|
2. **Anything that changes or reaches out is held.** It appears at http://127.0.0.1:47653 with the command, what it can affect, and a countdown. Answer with `A` or `D`, or shift for always and never. Nobody answering means denied after two minutes.
|
|
207
228
|
3. **The record builds itself** when the session ends.
|
|
208
|
-
4. **At `git push`** the hook merges every session on that branch, prints what was refused, and
|
|
229
|
+
4. **At `git push`** the hook merges every session on that branch, prints what was refused, and posts it to the branch's open pull request. When the agent opens the pull request itself, the record is posted right then.
|
|
209
230
|
5. **Your reviewer opens the pull request** and the record is there, as one comment that updates on every push rather than a new one each time.
|
|
210
231
|
|
|
211
232
|
### For a team
|
|
@@ -356,16 +377,23 @@ node scripts/install-push-hook.mjs ~/code/my-app
|
|
|
356
377
|
```
|
|
357
378
|
|
|
358
379
|
That installs a `pre-push` hook. On your next push it builds the branch record,
|
|
359
|
-
prints what it found including anything refused, and
|
|
360
|
-
pull request —
|
|
361
|
-
|
|
380
|
+
prints what it found including anything refused, and posts it to the branch's
|
|
381
|
+
open pull request — one comment, updated on every push after that.
|
|
382
|
+
|
|
383
|
+
It used to ask first, in the terminal. That never happened in practice: the push
|
|
384
|
+
that matters is the agent's own ("raise a PR"), an agent's push has no terminal,
|
|
385
|
+
and so nothing was ever posted and every reviewer saw an empty pull request. The
|
|
386
|
+
pull request is also usually opened after that push, so Nearly now posts as soon
|
|
387
|
+
as it sees the agent run `gh pr create` too.
|
|
362
388
|
|
|
363
389
|
Three rules it follows:
|
|
364
390
|
|
|
365
391
|
- **It never blocks a push.** No sessions on the branch, no server, a crash, a
|
|
366
392
|
timeout: it prints one dim line at most and exits 0.
|
|
367
|
-
- **
|
|
368
|
-
|
|
393
|
+
- **Posting is on unless you turn it off.** The record includes every prompt word
|
|
394
|
+
for word. `nearly --no-post` stops posting for a repo and `nearly --post` turns
|
|
395
|
+
it back on; `NEARLY_NO_POST=1` stops it everywhere. A merged or closed pull
|
|
396
|
+
request is never posted to.
|
|
369
397
|
- **It stays fast.** Narration is skipped by default, because a minute of `say`
|
|
370
398
|
at every push is not acceptable. Set `NEARLY_AUDIO=1` when you want the good one.
|
|
371
399
|
|
|
@@ -472,6 +500,7 @@ The claim this project makes is testable: a reviewer who sees the session record
|
|
|
472
500
|
- `server/index.mjs`, spawn sessions, hooks, policy, recorder, undo
|
|
473
501
|
- `ui/index.html`, sessions, triage of pending approvals, rules, log
|
|
474
502
|
- `scripts/attach.mjs`, install or remove the hooks in a repo of your own; `scripts/post-recap.mjs`, comment the recap on its PR
|
|
503
|
+
- `scripts/outside.mjs`, the hook in Claude Code's user settings that gates sessions opened in another folder
|
|
475
504
|
- `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
|
|
476
505
|
- `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
|
|
477
506
|
- `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.
|
|
3
|
+
"version": "0.1.20",
|
|
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/posting.test.mjs test/runtime.test.mjs"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/scripts/attach.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
// gated and recorded whether you start them in a terminal, in VS Code or in
|
|
11
11
|
// JetBrains. Claude Code always; Cursor, Antigravity, Copilot, Codex, Gemini
|
|
12
12
|
// and Windsurf when the repo shows signs of them, or on --agent=
|
|
13
|
-
// · a git pre-push hook, so the record is
|
|
13
|
+
// · a git pre-push hook, so the record is posted 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,9 @@ 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';
|
|
33
|
+
import { postingOff, setPosting } from './posting.mjs';
|
|
29
34
|
|
|
30
35
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
31
36
|
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
@@ -146,6 +151,7 @@ const off = argv.includes('--off') || argv.includes('--detach');
|
|
|
146
151
|
// Now the never-rules block with nobody present, everything else is done and
|
|
147
152
|
// recorded, and holding for approval is something you turn on while watching.
|
|
148
153
|
const supervise = argv.includes('--supervise');
|
|
154
|
+
const localOnly = argv.includes('--local-only');
|
|
149
155
|
const auto = !supervise;
|
|
150
156
|
// The repo is wherever git says its top is. Looking only for a .git folder in
|
|
151
157
|
// the current directory rejected every subfolder, which is where people usually
|
|
@@ -259,6 +265,28 @@ try {
|
|
|
259
265
|
notes.push(`could not remember this repo for the dashboard: ${e.message}`);
|
|
260
266
|
}
|
|
261
267
|
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Sessions opened in another folder
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Claude Code only reads this repo's hooks when a session starts here. The hook
|
|
272
|
+
// in its user settings covers the rest, and stays only while some repo still
|
|
273
|
+
// has Nearly on. Never from a pinned npx call: that would put a registry lookup
|
|
274
|
+
// in front of every tool call in every session on the machine.
|
|
275
|
+
if (!off && (argv.includes('--no-post') || argv.includes('--post'))) {
|
|
276
|
+
try { setPosting(repo, argv.includes('--post')); } catch (e) { notes.push(`could not save the posting choice: ${e.message}`); }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let reach = null;
|
|
280
|
+
const claudeWired = wired.some((a) => a.id === 'claude-code');
|
|
281
|
+
if (!off && claudeWired && !localOnly && (installed || runtime || !fromPackage)) {
|
|
282
|
+
try {
|
|
283
|
+
reach = installOutside((ev) => hookCmd(ev));
|
|
284
|
+
if (reach.error) { notes.push(`sessions opened in other folders are not covered: ${reach.error}`); reach = null; }
|
|
285
|
+
} catch (e) { notes.push(`sessions opened in other folders are not covered: ${e.message}`); }
|
|
286
|
+
} else if (off || localOnly) {
|
|
287
|
+
try { if (!attachedRepos().length) removeOutside(); } catch { /* leave it: it answers nothing without repos */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
262
290
|
// ---------------------------------------------------------------------------
|
|
263
291
|
// A server from somewhere else, already holding the port
|
|
264
292
|
// ---------------------------------------------------------------------------
|
|
@@ -347,6 +375,7 @@ if (off) {
|
|
|
347
375
|
? ` hooks removed: ${wired.map((a) => a.name).join(', ')}`
|
|
348
376
|
: ' no agent hooks of ours were installed');
|
|
349
377
|
console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
|
|
378
|
+
console.log(dim(' restart any agent session open here; it keeps the hooks it started with'));
|
|
350
379
|
console.log('');
|
|
351
380
|
process.exit(0);
|
|
352
381
|
}
|
|
@@ -364,9 +393,32 @@ for (const a of wired) {
|
|
|
364
393
|
const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
|
|
365
394
|
console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
|
|
366
395
|
}
|
|
396
|
+
if (reach) {
|
|
397
|
+
console.log(` ${ok('·')} Claude Code sessions opened in another folder are gated too, once they work in this repo`);
|
|
398
|
+
console.log(` ${dim(`a hook in ${userSettingsFile().replace(process.env.HOME || '~', '~')}; it stays silent everywhere else`)}`);
|
|
399
|
+
} else if (claudeWired && !off) {
|
|
400
|
+
console.log(` ${ok('·')} ${dim('only Claude Code sessions started in this folder are gated')}`);
|
|
401
|
+
}
|
|
402
|
+
// Hooks are read when a session starts. Someone who turns this on and carries on
|
|
403
|
+
// in the window they already had is not gated at all, and nothing says so.
|
|
404
|
+
if (wired.length) {
|
|
405
|
+
console.log(` ${bold('·')} ${bold(`restart any ${wired.map((a) => a.name).join(' or ')} session already open`)} ${dim('— hooks are read when a session starts')}`);
|
|
406
|
+
}
|
|
367
407
|
console.log(` ${ok('·')} ${dim(updateNote())}`);
|
|
368
408
|
if (push.status === 0) {
|
|
369
|
-
|
|
409
|
+
// Only sessions from now on are recorded, and the record reaches a pull
|
|
410
|
+
// request on a push. Said here, because the natural thing is to look at a pull
|
|
411
|
+
// request that already exists and wonder where the record is.
|
|
412
|
+
const pr = prForBranch(repo);
|
|
413
|
+
if (postingOff(repo)) {
|
|
414
|
+
console.log(` ${ok('·')} the record is built on push but not posted ${dim('— nearly --post puts it on the pull request')}`);
|
|
415
|
+
} else if (pr.state === 'open') {
|
|
416
|
+
console.log(` ${ok('·')} the record is posted to #${pr.number} on your next push ${dim('— sessions from now on; nearly --no-post stops it')}`);
|
|
417
|
+
} else if (pr.state === 'merged' || pr.state === 'closed') {
|
|
418
|
+
console.log(` ${ok('·')} the record is posted on your next push ${dim(`— #${pr.number} for this branch is ${pr.state}, so open a new pull request first`)}`);
|
|
419
|
+
} else {
|
|
420
|
+
console.log(` ${ok('·')} the record is posted to the pull request when it is opened, and updated on every push ${dim('— nearly --no-post stops it')}`);
|
|
421
|
+
}
|
|
370
422
|
} else {
|
|
371
423
|
// All of it: when a pre-push hook of yours is already there, the lines after
|
|
372
424
|
// the first are the ones that say how to add Nearly to it by hand.
|
package/scripts/build-recap.mjs
CHANGED
|
@@ -160,6 +160,22 @@ function patch(cwd, range, maxLines = 48) {
|
|
|
160
160
|
const short = (s, n = 90) => { s = String(s ?? '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
161
161
|
const secs = (ms) => (ms / 1000).toFixed(1);
|
|
162
162
|
const plural = (n, w, ws = w + 's') => `${n} ${n === 1 ? w : ws}`;
|
|
163
|
+
// "5221s" is a number nobody reads as an hour and a half.
|
|
164
|
+
const clock = (s) => {
|
|
165
|
+
s = Math.round(s);
|
|
166
|
+
if (s < 90) return `${s}s`;
|
|
167
|
+
const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
|
|
168
|
+
return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${Math.round(s / 60)}m`;
|
|
169
|
+
};
|
|
170
|
+
const spoken = (s) => {
|
|
171
|
+
s = Math.round(s);
|
|
172
|
+
if (s < 90) return plural(s, 'second');
|
|
173
|
+
const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
|
|
174
|
+
return h ? `${plural(h, 'hour')}${m ? ` ${plural(m, 'minute')}` : ''}` : plural(Math.round(s / 60), 'minute');
|
|
175
|
+
};
|
|
176
|
+
// Tools that only look. Anything else allowed without asking changed something,
|
|
177
|
+
// and calling it read-only told a reviewer that edits were only reads.
|
|
178
|
+
const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite']);
|
|
163
179
|
|
|
164
180
|
function describeInput(tool, input = {}) {
|
|
165
181
|
if (tool === 'Bash') return input.command || '';
|
|
@@ -253,6 +269,9 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
253
269
|
// itself the fact. Leaving it implied reads as "nothing needed approving"
|
|
254
270
|
// when what happened is that nobody was asked.
|
|
255
271
|
const unattended = decisions.some((d) => d.scope === 'auto');
|
|
272
|
+
// With nobody watching, "waiting on a human: 0.0s" and "asked Anuj: 0" are not
|
|
273
|
+
// facts about the work, just columns about a supervisor who was not there.
|
|
274
|
+
const nobodyThere = unattended && !humanDecisions.length && !humanWaitMs && !asks.length;
|
|
256
275
|
scenes.push({
|
|
257
276
|
kind: 'cover',
|
|
258
277
|
title: headlineBits.length ? headlineBits.join(', ') : 'A session with nothing to flag',
|
|
@@ -268,14 +287,14 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
268
287
|
repo: worktree ? basename(worktree) : null,
|
|
269
288
|
branch: created?.branch || null,
|
|
270
289
|
stats: [
|
|
271
|
-
['Ran for',
|
|
272
|
-
[V('Waiting on a human', 'Waiting on you'), `${secs(humanWaitMs)}s`, 'ask'],
|
|
290
|
+
['Ran for', clock(durS), ''],
|
|
291
|
+
...(nobodyThere ? [] : [[V('Waiting on a human', 'Waiting on you'), `${secs(humanWaitMs)}s`, 'ask']]),
|
|
273
292
|
['Tool calls', String(toolUses.length), ''],
|
|
274
|
-
[V('Asked ' + AUTHOR, 'Asked you'), String(humanDecisions.length), ''],
|
|
293
|
+
...(nobodyThere ? [] : [[V('Asked ' + AUTHOR, 'Asked you'), String(humanDecisions.length), '']]),
|
|
275
294
|
['Refused', String(denied.length), denied.length ? 'deny' : ''],
|
|
276
295
|
['Rolled back', String(undos.length), undos.length ? 'undo' : ''],
|
|
277
296
|
],
|
|
278
|
-
narration: `${sbRuns > 1 ? `${plural(sbRuns, 'agent session')} on this branch, ${durS
|
|
297
|
+
narration: `${sbRuns > 1 ? `${plural(sbRuns, 'agent session')} on this branch, ${spoken(durS)} in total` : `Agent ${name} ran for ${spoken(durS)}`}${nobodyThere ? ', with nobody watching' : ` under ${supPoss} supervision`}. ${plural(toolUses.length, 'tool call')}, ${nobodyThere ? '' : `${humanDecisions.length} held for a decision, `}${denied.length} refused${undos.length ? `, ${plural(undos.length, 'turn')} rolled back` : ''}.`,
|
|
279
298
|
});
|
|
280
299
|
|
|
281
300
|
// 2. intent. One scene per thing that was asked for, in order, so a branch
|
|
@@ -290,10 +309,13 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
290
309
|
const flushQuiet = () => {
|
|
291
310
|
if (!quiet.length) return;
|
|
292
311
|
const tools = [...new Set(quiet.map((q) => q.tool))];
|
|
312
|
+
const onlyReads = quiet.every((q) => READS.has(q.tool));
|
|
313
|
+
const byRule = quiet.every((q) => !q.auto);
|
|
293
314
|
scenes.push({
|
|
294
315
|
kind: 'quiet',
|
|
316
|
+
onlyReads,
|
|
295
317
|
items: quiet.map((q) => ({ tool: q.tool, sub: short(describeInput(q.tool, q.input), 80) })),
|
|
296
|
-
narration: `${plural(quiet.length, 'read-only step')} ran without asking: ${tools.join(', ')}. Logged, not gated
|
|
318
|
+
narration: `${plural(quiet.length, onlyReads ? 'read-only step' : 'step')} ran without asking: ${tools.join(', ')}. ${onlyReads || byRule ? 'Logged, not gated.' : 'Nobody was watching, so nobody was asked; every one is logged.'}`,
|
|
297
319
|
});
|
|
298
320
|
quiet = [];
|
|
299
321
|
};
|
|
@@ -318,7 +340,7 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
318
340
|
const tool = e.tool;
|
|
319
341
|
const input = ask?.input ?? e.input ?? {};
|
|
320
342
|
const res = results.find((r) => r.id === e.id);
|
|
321
|
-
if (e.tier === 'log') { quiet.push({ tool, input }); continue; }
|
|
343
|
+
if (e.tier === 'log') { quiet.push({ tool, input, auto: e.scope === 'auto' }); continue; }
|
|
322
344
|
flushQuiet();
|
|
323
345
|
const human = e.waitedMs != null;
|
|
324
346
|
const w = human ? secs(e.waitedMs) : null;
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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 =
|
|
211
|
-
if (pr.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
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,21 @@
|
|
|
1
|
+
// Whether a repo's record is posted to its pull request on push. On unless it
|
|
2
|
+
// was turned off for that repo, or for everything with NEARLY_NO_POST=1.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { paths } from '../server/paths.mjs';
|
|
6
|
+
|
|
7
|
+
const key = (repo) => { try { return realpathSync.native(repo); } catch { return repo; } };
|
|
8
|
+
const read = () => { try { return existsSync(paths.config()) ? JSON.parse(readFileSync(paths.config(), 'utf8')) : {}; } catch { return {}; } };
|
|
9
|
+
|
|
10
|
+
export function postingOff(repo) {
|
|
11
|
+
if (process.env.NEARLY_NO_POST === '1') return true;
|
|
12
|
+
return (read().noPost || []).includes(key(repo));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setPosting(repo, on) {
|
|
16
|
+
const cfg = read();
|
|
17
|
+
const list = new Set(cfg.noPost || []);
|
|
18
|
+
if (on) list.delete(key(repo)); else list.add(key(repo));
|
|
19
|
+
cfg.noPost = [...list];
|
|
20
|
+
writeFileSync(paths.config(), JSON.stringify(cfg, null, 2) + '\n');
|
|
21
|
+
}
|
|
@@ -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
|
+
}
|
package/scripts/push-record.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// What the pre-push hook runs. Builds the branch's session record, shows what
|
|
2
|
-
// it found, and
|
|
2
|
+
// it found, and puts it on the branch's open pull request.
|
|
3
|
+
//
|
|
4
|
+
// It used to ask first, in the terminal. But the push that matters is usually
|
|
5
|
+
// made by the agent — "raise a PR" — and an agent's push has no terminal, so the
|
|
6
|
+
// question was never asked and nothing was ever posted. The reviewer saw an
|
|
7
|
+
// empty pull request on every branch Nearly had recorded. Now it posts, as one
|
|
8
|
+
// comment that each push updates; `nearly --no-post` stops it for a repo.
|
|
3
9
|
//
|
|
4
10
|
// node scripts/push-record.mjs <repo-path>
|
|
5
11
|
//
|
|
@@ -9,7 +15,6 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
15
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
10
16
|
import { fileURLToPath } from 'node:url';
|
|
11
17
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
-
import { createInterface } from 'node:readline';
|
|
13
18
|
import { paths } from '../server/paths.mjs';
|
|
14
19
|
|
|
15
20
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
@@ -108,30 +113,23 @@ if (!hasGh) {
|
|
|
108
113
|
console.log('');
|
|
109
114
|
process.exit(0);
|
|
110
115
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
const { prForBranch } = await import('./pr-state.mjs');
|
|
117
|
+
const pr = prForBranch(repo);
|
|
118
|
+
if (pr.state !== 'open') {
|
|
119
|
+
const why = pr.state === 'signed-out'
|
|
114
120
|
? 'gh is installed but not signed in. Run `gh auth login`, then push again.'
|
|
115
|
-
:
|
|
121
|
+
: pr.state === 'merged' || pr.state === 'closed'
|
|
122
|
+
// Posting onto a finished conversation reaches nobody who is reviewing.
|
|
123
|
+
? `The pull request for this branch, #${pr.number}, is already ${pr.state}. Open a new one, then push again to attach the record.`
|
|
124
|
+
: 'No open pull request for this branch yet. Raise one, then push again to attach the record.';
|
|
116
125
|
console.log(dim(` ${why}`));
|
|
117
126
|
console.log('');
|
|
118
127
|
process.exit(0);
|
|
119
128
|
}
|
|
120
|
-
const prUrl = (() => { try { return JSON.parse(pr.stdout).url; } catch { return null; } })();
|
|
121
|
-
|
|
122
|
-
if (process.env.NEARLY_NO_TTY === '1' || !process.stdin.isTTY) {
|
|
123
|
-
console.log(dim(' No terminal to ask on, so nothing was posted.'));
|
|
124
|
-
console.log(dim(` Post it yourself: node scripts/post-recap.mjs ${slug}${URL_BASE ? ` --url-base ${URL_BASE}` : ''}`));
|
|
125
|
-
console.log('');
|
|
126
|
-
process.exit(0);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
130
|
-
const answer = await new Promise((r) => rl.question(` Post this record to ${prUrl || 'the pull request'}? [y/N] `, r))
|
|
131
|
-
.finally(() => rl.close());
|
|
132
129
|
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const { postingOff } = await import('./posting.mjs');
|
|
131
|
+
if (postingOff(repo)) {
|
|
132
|
+
console.log(dim(` Not posted: posting is off for this repo. \`nearly --post\` turns it back on.`));
|
|
135
133
|
console.log('');
|
|
136
134
|
process.exit(0);
|
|
137
135
|
}
|
|
@@ -140,7 +138,7 @@ const postArgs = [join(root, 'scripts', 'post-recap.mjs'), slug];
|
|
|
140
138
|
if (URL_BASE) postArgs.push('--url-base', URL_BASE);
|
|
141
139
|
const post = spawnSync(process.execPath, postArgs, { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
|
142
140
|
console.log(post.status === 0
|
|
143
|
-
? `
|
|
141
|
+
? ` Record ${(post.stdout || '').trim()} ${dim('(nearly --no-post stops this)')}`
|
|
144
142
|
: red(` Could not post: ${(post.stderr || post.stdout || '').trim().split('\n').pop()}`));
|
|
145
143
|
console.log('');
|
|
146
144
|
process.exit(0);
|
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,46 @@ function modelFromTranscript(p) {
|
|
|
242
243
|
return null;
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
// "Raise a PR" pushes the branch first and opens the pull request second. The
|
|
247
|
+
// push found no pull request to post to, and nothing happens after the pull
|
|
248
|
+
// request exists — so on the agent's own branch, the one thing a reviewer opens
|
|
249
|
+
// never had a record on it. When an agent opens one, post straight away, from a
|
|
250
|
+
// process of its own so the agent is not kept waiting.
|
|
251
|
+
const OPENED = /\bgh\s+pr\s+create\b/;
|
|
252
|
+
function postWhenOpened(s, hook) {
|
|
253
|
+
const cmd = hook.tool_input?.command;
|
|
254
|
+
if (typeof cmd !== 'string' || !OPENED.test(cmd)) return;
|
|
255
|
+
const said = typeof hook.tool_response === 'string' ? hook.tool_response : JSON.stringify(hook.tool_response ?? '');
|
|
256
|
+
if (!/\/pull\/\d+/.test(said)) return; // it did not open one
|
|
257
|
+
try {
|
|
258
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'scripts', 'push-record.mjs'), s.worktree], {
|
|
259
|
+
cwd: s.worktree, detached: true, stdio: 'ignore',
|
|
260
|
+
env: { ...process.env, NEARLY_NO_UPDATE: '1', NEARLY_NO_TTY: '1' },
|
|
261
|
+
});
|
|
262
|
+
child.on('error', () => { /* the next push posts it */ });
|
|
263
|
+
child.unref();
|
|
264
|
+
record(s.id, { type: 'hook', event: 'record-posting', detail: 'pull request opened' });
|
|
265
|
+
} catch { /* the next push posts it */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The most recent thing the person typed, from a Claude Code transcript.
|
|
269
|
+
function promptFromTranscript(p) {
|
|
270
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
271
|
+
try {
|
|
272
|
+
let last = null;
|
|
273
|
+
for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
|
|
274
|
+
if (!l) continue;
|
|
275
|
+
let j; try { j = JSON.parse(l); } catch { continue; }
|
|
276
|
+
if (j.type !== 'user' || j.isMeta) continue;
|
|
277
|
+
const c = j.message?.content;
|
|
278
|
+
const text = typeof c === 'string' ? c
|
|
279
|
+
: Array.isArray(c) && !c.some((x) => x?.type === 'tool_result') ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
|
|
280
|
+
if (text && !/^<(?:command-|local-command|system-reminder)/.test(text.trim())) last = text;
|
|
281
|
+
}
|
|
282
|
+
return last;
|
|
283
|
+
} catch { return null; }
|
|
284
|
+
}
|
|
285
|
+
|
|
245
286
|
function turnDiff(cwd, base, maxLines = 200) {
|
|
246
287
|
const from = base || 'HEAD';
|
|
247
288
|
const numstat = git(cwd, ['diff', '--numstat', from]);
|
|
@@ -357,6 +398,8 @@ function decide(sid, id, decision, why, scope = 'once') {
|
|
|
357
398
|
// asked gets the same answer; answering only the last one left the first hook
|
|
358
399
|
// hanging until the agent's own timeout, which looks like the agent freezing.
|
|
359
400
|
for (const r of p.responders) r(decision, why);
|
|
401
|
+
s.answered ||= new Map();
|
|
402
|
+
s.answered.set(id, [decision, why]);
|
|
360
403
|
record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
|
|
361
404
|
if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
|
|
362
405
|
broadcast({ type: 'session-state', session: sid, state: s.state });
|
|
@@ -389,10 +432,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
389
432
|
try { hook = JSON.parse(await readBody(req) || '{}'); } catch { /* keep {} */ }
|
|
390
433
|
const ev = url.pathname.slice('/hooks/'.length);
|
|
391
434
|
const attach = url.searchParams.get('attach');
|
|
435
|
+
// From the hook in Claude Code's user settings: a session started outside the
|
|
436
|
+
// repo. With a repo it touched that repo; without one, it is ours only if an
|
|
437
|
+
// earlier call already made it so, and otherwise gets no answer at all.
|
|
438
|
+
const outside = url.searchParams.get('outside') === '1';
|
|
439
|
+
const outsideRepo = outside ? url.searchParams.get('repo') : null;
|
|
440
|
+
if (outside && !attach && !(hook.session_id && sessions.has(hook.session_id))) return hookOk(res);
|
|
392
441
|
let sidResolved = sidParam;
|
|
393
|
-
if (!sidResolved && attach && hook.session_id) {
|
|
442
|
+
if (!sidResolved && (attach || outside) && hook.session_id) {
|
|
394
443
|
sidResolved = hook.session_id;
|
|
395
|
-
if (!sessions.has(sidResolved)
|
|
444
|
+
if (!sessions.has(sidResolved) && attach) {
|
|
445
|
+
const made = attachSession({ id: sidResolved, name: attach.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo', cwd: outsideRepo || hook.cwd || process.cwd() });
|
|
446
|
+
// Its prompt went by before anything said the session was ours. The
|
|
447
|
+
// transcript still has it, and a record that starts mid-task without
|
|
448
|
+
// saying what was asked is missing the part a reviewer reads first.
|
|
449
|
+
if (outsideRepo) {
|
|
450
|
+
made.outside = true;
|
|
451
|
+
try { rememberOutside(sidResolved, outsideRepo); } catch { /* it is re-found by path next time */ }
|
|
452
|
+
// Later calls from this session may not mention the repo, and arrive
|
|
453
|
+
// without its settings; they keep the ones it was gated with.
|
|
454
|
+
made.auto = url.searchParams.get('auto') === '1';
|
|
455
|
+
const asked = promptFromTranscript(hook.transcript_path);
|
|
456
|
+
if (asked) { made.lastPrompt = { text: asked, at: Date.now() }; record(sidResolved, { type: 'prompt', text: asked.slice(0, 4000) }); }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
396
459
|
}
|
|
397
460
|
const s = sessions.get(sidResolved);
|
|
398
461
|
const sid = sidResolved;
|
|
@@ -410,7 +473,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
410
473
|
return hookOk(res);
|
|
411
474
|
}
|
|
412
475
|
if (ev === 'prompt') {
|
|
413
|
-
|
|
476
|
+
// The same prompt twice within a few seconds is one prompt heard by two hooks.
|
|
477
|
+
const text = String(hook.prompt || '');
|
|
478
|
+
const echo = s && s.lastPrompt && s.lastPrompt.text === text && Date.now() - s.lastPrompt.at < 5000;
|
|
479
|
+
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
480
|
return hookOk(res);
|
|
415
481
|
}
|
|
416
482
|
if (ev === 'session-end') {
|
|
@@ -432,7 +498,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
432
498
|
// stalls the run and teaches people to turn the gate off. The never-rules
|
|
433
499
|
// still bite, because those never needed a person. Everything that would
|
|
434
500
|
// have been asked is done and written down instead.
|
|
435
|
-
const unattended = url.searchParams.get('auto') === '1';
|
|
501
|
+
const unattended = url.searchParams.get('auto') === '1' || (outside && !!s?.auto);
|
|
436
502
|
let { tier, reason } = classifyWith(hook, rules);
|
|
437
503
|
if (unattended && tier === 'ask') { tier = 'log'; reason = 'allowed unattended — nobody was asked'; }
|
|
438
504
|
const id = hook.tool_use_id || randomUUID();
|
|
@@ -444,10 +510,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
444
510
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
|
|
445
511
|
});
|
|
446
512
|
if (!s) return respond('deny', 'unknown session');
|
|
447
|
-
|
|
513
|
+
// Already answered: the same call through a second hook — a repo's own and
|
|
514
|
+
// the user-level one, or VS Code reading two config files. Same answer,
|
|
515
|
+
// written down once.
|
|
516
|
+
if (hook.tool_use_id && s.answered?.has(id)) return respond(...s.answered.get(id));
|
|
517
|
+
const answer = (decision, why) => {
|
|
518
|
+
if (hook.tool_use_id) {
|
|
519
|
+
s.answered ||= new Map();
|
|
520
|
+
s.answered.set(id, [decision, why]);
|
|
521
|
+
if (s.answered.size > 500) s.answered.delete(s.answered.keys().next().value);
|
|
522
|
+
}
|
|
523
|
+
return respond(decision, why);
|
|
524
|
+
};
|
|
525
|
+
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
526
|
if (tier === 'log') {
|
|
449
527
|
record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: unattended ? 'auto' : 'policy', tool: shown, input: hook.tool_input, tier });
|
|
450
|
-
return
|
|
528
|
+
return answer('allow', unattended ? reason : `do and log (${reason})`);
|
|
451
529
|
}
|
|
452
530
|
// ask: hold the response until the UI decides, or fail closed
|
|
453
531
|
// A harness may say it will not wait as long as we would. It can shorten
|
|
@@ -470,10 +548,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
470
548
|
}
|
|
471
549
|
|
|
472
550
|
if (ev === 'post-tool') {
|
|
473
|
-
|
|
551
|
+
const seen = s && hook.tool_use_id && s.posted?.has(hook.tool_use_id);
|
|
552
|
+
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); }
|
|
553
|
+
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 ?? '') });
|
|
554
|
+
if (s && !seen && s.attached && s.worktree) postWhenOpened(s, hook);
|
|
474
555
|
return hookOk(res);
|
|
475
556
|
}
|
|
476
557
|
if (ev === 'stop') {
|
|
558
|
+
// One turn ending, heard by two hooks, is still one turn.
|
|
559
|
+
if (s && s.attached && s.lastStopAt && Date.now() - s.lastStopAt < 3000) return hookOk(res);
|
|
560
|
+
if (s && s.attached) s.lastStopAt = Date.now();
|
|
477
561
|
if (s && s.attached) {
|
|
478
562
|
s.turns += 1;
|
|
479
563
|
s.state = 'idle';
|
package/server/marks.mjs
ADDED
|
@@ -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
|
};
|
package/server/policy.mjs
CHANGED
|
@@ -270,7 +270,10 @@ function expand(raw, st) {
|
|
|
270
270
|
let s = String(raw);
|
|
271
271
|
if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
|
|
272
272
|
else if (/^~[^/\\]/.test(s)) return { unknown: `another user's home directory (${raw})` };
|
|
273
|
-
|
|
273
|
+
// Variables set earlier on the same line count: `S=/tmp/scratch; rm -f $S/x`
|
|
274
|
+
// is a scratch file, and refusing it as "decided at run time" stopped real work.
|
|
275
|
+
// One set to something unknowable stays unknowable (null).
|
|
276
|
+
const vars = { HOME: os.homedir(), USERPROFILE: os.homedir(), TMPDIR: process.env.TMPDIR || os.tmpdir(), TMP: os.tmpdir(), TEMP: os.tmpdir(), PWD: st.cwd, ...(st.vars || {}) };
|
|
274
277
|
s = s.replace(/\$\(\s*pwd\s*\)/g, () => st.cwd ?? '\0');
|
|
275
278
|
s = s.replace(/\$\{?env:([A-Za-z_]+)\}?/gi, (_, k) => vars[k.toUpperCase()] ?? '\0');
|
|
276
279
|
s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, k) => vars[k] ?? '\0');
|
|
@@ -742,7 +745,13 @@ function stageReason(stage, pipe, idx, st, depth) {
|
|
|
742
745
|
|
|
743
746
|
// What a command changes for the commands after it on the same line.
|
|
744
747
|
function applyState(stage, st) {
|
|
745
|
-
const { argv, prog } = unwrap(stage.words);
|
|
748
|
+
const { argv, prog, assigns } = unwrap(stage.words);
|
|
749
|
+
// `NAME=value` on its own sets a shell variable for the rest of the line. With
|
|
750
|
+
// a command after it, it only sets that command's environment, and the shell
|
|
751
|
+
// has already expanded the line by then — so it changes nothing here.
|
|
752
|
+
const remember = (k, v) => { const x = expand(v, st); (st.vars ||= {})[k] = x.path ?? null; };
|
|
753
|
+
if (!prog) { for (const [k, v] of Object.entries(assigns)) remember(k, v); return; }
|
|
754
|
+
if (prog === 'unset') { for (const a of argv.slice(1)) if (st.vars) delete st.vars[a]; return; }
|
|
746
755
|
if (['cd', 'pushd', 'set-location', 'sl', 'chdir'].includes(prog)) {
|
|
747
756
|
const target = argv.slice(1).find((a) => !a.startsWith('-'));
|
|
748
757
|
if (!target) { st.cwd = os.homedir(); return; }
|
|
@@ -753,7 +762,12 @@ function applyState(stage, st) {
|
|
|
753
762
|
}
|
|
754
763
|
if (prog === 'popd') { st.cwd = null; return; }
|
|
755
764
|
if (prog === 'export') {
|
|
756
|
-
for (const a of argv.slice(1)) {
|
|
765
|
+
for (const a of argv.slice(1)) {
|
|
766
|
+
const m = a.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
767
|
+
if (!m) continue;
|
|
768
|
+
if (m[1] === 'GIT_DIR') st.gitDir = m[2];
|
|
769
|
+
remember(m[1], m[2]);
|
|
770
|
+
}
|
|
757
771
|
return;
|
|
758
772
|
}
|
|
759
773
|
if (prog !== 'git') return;
|
|
@@ -795,7 +809,7 @@ function analyze(src, st, depth = 0) {
|
|
|
795
809
|
function stateFor(cwd) {
|
|
796
810
|
const dir = cwd ? (real(cwd) || cwd) : null;
|
|
797
811
|
const top = dir ? git(dir, ['rev-parse', '--show-toplevel']) : null;
|
|
798
|
-
return { cwd: dir, root: top ? (real(top) || top) : null, branch: null, gitDir: null, aliases: {} };
|
|
812
|
+
return { cwd: dir, root: top ? (real(top) || top) : null, branch: null, gitDir: null, aliases: {}, vars: {} };
|
|
799
813
|
}
|
|
800
814
|
|
|
801
815
|
// The reason a command must never run, or null. Exported for the tests, which
|
package/ui/recap.template.html
CHANGED
|
@@ -300,7 +300,7 @@ const render = {
|
|
|
300
300
|
quiet: (s) => `
|
|
301
301
|
<div class="eyebrow"><span class="lbl">Ran without asking</span>${T('auto', 'log tier')}</div>
|
|
302
302
|
<div class="rows">${s.items.map((i) => `<div class="row"><span class="t">${esc(i.tool)}</span><span class="s" title="${esc(i.sub)}">${esc(i.sub)}</span>${T('auto', 'receipt')}</div>`).join('')}</div>
|
|
303
|
-
<div class="sub" style="margin-top:auto"
|
|
303
|
+
<div class="sub" style="margin-top:auto">${s.onlyReads === false ? 'Allowed without anyone being asked. Every call is on the record.' : 'Read-only tools run on the log tier. Nobody was asked, but every call is on the record.'}</div>`,
|
|
304
304
|
|
|
305
305
|
decision: (s) => {
|
|
306
306
|
const d = s.tier === 'never' ? 'never' : s.decision;
|