nearly-cli 0.1.12 → 0.1.16
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 +64 -6
- package/bin/nearly.mjs +12 -2
- package/package.json +2 -2
- package/scripts/agents.mjs +10 -6
- package/scripts/attach.mjs +141 -35
- package/scripts/build-recap.mjs +50 -11
- package/scripts/detect.mjs +2 -1
- package/scripts/doctor.mjs +30 -13
- package/scripts/hook.mjs +12 -5
- package/scripts/install-push-hook.mjs +29 -11
- package/scripts/post-recap.mjs +1 -1
- package/scripts/push-record.mjs +7 -5
- package/scripts/runtime.mjs +84 -0
- package/scripts/update-check.mjs +58 -5
- package/server/adapters.mjs +21 -5
- package/server/index.mjs +14 -4
- package/server/policy.mjs +821 -17
- package/server/reclaim.mjs +22 -2
- package/ui/recap.template.html +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,19 @@ Nothing to configure, no server to start, and `nearly off` removes all of it.
|
|
|
159
159
|
Run it again in any other repo you want recorded. After the first time the
|
|
160
160
|
command is just `nearly`.
|
|
161
161
|
|
|
162
|
+
### Where it installs
|
|
163
|
+
|
|
164
|
+
`npx nearly-cli` installs a copy into `~/.nearly/runtime`, and the hooks call
|
|
165
|
+
that copy directly. Not `npm install -g`, which it used to use and which failed
|
|
166
|
+
silently in three ordinary situations: on any up-to-date Windows machine, where
|
|
167
|
+
Node will not start `npm.cmd` without a shell; on a Mac whose Node came from the
|
|
168
|
+
official installer, where the global folder belongs to root; and whenever npx
|
|
169
|
+
itself was running it, because npx passes its own settings down to the npm it
|
|
170
|
+
starts. Each failure fell back to a pinned `npx -y nearly-cli@<version>` in every
|
|
171
|
+
hook — slower on every tool call, and stuck on that release for good. A folder
|
|
172
|
+
you always own avoids all three, and if the install still fails, `nearly` now
|
|
173
|
+
prints npm's actual reason instead of a pointer to a log file.
|
|
174
|
+
|
|
162
175
|
### Upgrading
|
|
163
176
|
|
|
164
177
|
It updates itself. When you run a command and a newer version exists, Nearly
|
|
@@ -175,7 +188,7 @@ Four rules keep that from being something you regret installing:
|
|
|
175
188
|
before you trust it.
|
|
176
189
|
- **Never silent.** An update that happened without being mentioned is
|
|
177
190
|
indistinguishable from a compromise, so it always says what it did.
|
|
178
|
-
- **Never fatal.** No network, a locked
|
|
191
|
+
- **Never fatal.** No network, a locked folder, a slow registry: you
|
|
179
192
|
keep the version you have, the command you ran still works, and it tells you
|
|
180
193
|
rather than leaving you to assume you are current.
|
|
181
194
|
|
|
@@ -212,18 +225,63 @@ All of them capture the moment a human refuses something. None of them passes it
|
|
|
212
225
|
|
|
213
226
|
**The gate here exists to produce the recording.** It is the instrument, not the product. If you already run Prempti, its audit trail is richer than ours and reading it as an input is the obvious next step; see *Roadmap*.
|
|
214
227
|
|
|
228
|
+
## Unattended by default
|
|
229
|
+
|
|
230
|
+
Turning Nearly on does not make your agent wait for anyone. Destructive commands
|
|
231
|
+
are refused outright — `rm -rf`, `git push`, `sudo`, anything touching `.env`,
|
|
232
|
+
`curl … | sh` — and everything else runs and is written down.
|
|
233
|
+
|
|
234
|
+
It used to hold every edit for a person to approve, which only works if a person
|
|
235
|
+
is watching the dashboard, and nothing told a new user it existed. In practice
|
|
236
|
+
that meant a Cursor session unable to write a single file, each edit waiting two
|
|
237
|
+
minutes and then being refused. Holding for approval is now something you turn
|
|
238
|
+
on while you are actually watching:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
nearly --supervise
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
This is not weaker than it sounds. The refusals that matter never needed a
|
|
245
|
+
person: they are rules, and they fire with nobody at the keyboard — including
|
|
246
|
+
under Claude Code's `bypassPermissions`, which skips its own prompts but not
|
|
247
|
+
the hooks Nearly runs in. With no one watching, the record is also the only
|
|
248
|
+
account of what the agent did, and it says so on the first line.
|
|
249
|
+
|
|
215
250
|
## The consent gradient
|
|
216
251
|
|
|
217
|
-
Every tool call passes through
|
|
252
|
+
Every tool call passes through a `PreToolUse` hook to this server, which sorts it into a tier:
|
|
218
253
|
|
|
219
|
-
| Tier | What happens |
|
|
254
|
+
| Tier | What happens | Covers |
|
|
220
255
|
|---|---|---|
|
|
221
|
-
| never |
|
|
222
|
-
| ask |
|
|
223
|
-
| log | allowed
|
|
256
|
+
| never | refused, nobody asked, recorded with the reason | anything that cannot be undone — see below |
|
|
257
|
+
| ask | only with `--supervise`: held until you decide; refused if nobody answers in 2 minutes | Bash, Edit, Write, WebFetch, Task |
|
|
258
|
+
| log | allowed and recorded | everything else — and, unattended, everything that would have been asked |
|
|
224
259
|
|
|
225
260
|
"Allow always" and "Never" turn a decision into a rule for the rest of the run, keyed by tool and first word of the command, or file extension for edits. In lab mode every turn is committed in the agent's worktree by the `Stop` hook, so **Undo turn** is a `git reset --hard HEAD~1`.
|
|
226
261
|
|
|
262
|
+
A call nobody answered is refused, but nobody refused it, and the record says so
|
|
263
|
+
— "nobody answered" — rather than crediting you with a decision you never made.
|
|
264
|
+
|
|
265
|
+
### What "never" means
|
|
266
|
+
|
|
267
|
+
One rule: **refuse what cannot be undone.**
|
|
268
|
+
|
|
269
|
+
- **Deleting.** Inside the repo, git gives it back, so `rm -rf node_modules` runs. Outside the repo, in your home directory, at the root, `.git` itself, or a path only known at run time (`$SOME_VAR`) is refused. Scratch space under the system temp folder is allowed, except for the temp folder itself, a folder that holds this repo, or another git repository.
|
|
270
|
+
- **Pushing.** A feature branch is how work reaches review, so it runs. A force-push, deleting a remote branch, `--mirror`, or pushing straight to `main`, `master` or the remote's default branch is refused — a bare `git push` asks git which branch you are on.
|
|
271
|
+
- **Local history git never had.** `git clean -fd`, `git reset --hard` over uncommitted work, `reflog expire`, `gc --prune=now` and `stash clear` are refused. `git clean -fdX`, which only removes ignored build output, is not.
|
|
272
|
+
- **Secrets.** Refused when a real secret would be shown to the agent, sent somewhere, or carried out of the repo: `cat .env`, `curl -F file=@.env`, `cp .env /tmp`, the Read tool on `.env`, `~/.aws/credentials`, `~/.ssh`. Not refused: `.env.example`, `--env-file .env`, `source .env`, `test -f .env`, `cp .env .env.bak`, and any file git already tracks, since that is already readable by anyone with the repo.
|
|
273
|
+
- **The rest.** `sudo` and its relatives, a download piped into anything that runs it, world-writable `chmod`, disk and filesystem tools, deleting a GitHub repository.
|
|
274
|
+
|
|
275
|
+
It reads a command the way a shell runs it: `cd ~ && rm -rf Documents` is refused because the `cd` moves where the delete lands; `bash -c`, `eval`, `$(…)`, `xargs`, `find -exec` and `npx rimraf` are unwrapped and checked; `\rm` and `/bin/rm` are `rm`; a path is followed through symlinks; `git -c x=y push --force` is still a force-push; `git checkout main && git push` is still a push to main. Windows `Remove-Item`, `rd /s` and `del /s` are covered. Any tool carrying a command is checked, not only one named `Bash`.
|
|
276
|
+
|
|
277
|
+
### What it is not
|
|
278
|
+
|
|
279
|
+
**It is not a sandbox.** It catches what a well-meaning agent actually types, and it was built by collecting every bypass two independent test runs could find — 126 destructive commands, all refused, and 97 ordinary ones, none refused, all kept as tests. But a determined or compromised agent can still get past a rule that reads text: code handed to an interpreter (`python -c`, `node -e`) is only inspected for the obvious, and a script the agent writes to a file and then runs is not inspected at all.
|
|
280
|
+
|
|
281
|
+
For isolation, run the agent in a container, a VM, or Claude Code's own sandbox, and use Nearly for what it is for — a record of what happened, including what was stopped, that reaches the person reviewing the code.
|
|
282
|
+
|
|
283
|
+
### Lab mode
|
|
284
|
+
|
|
227
285
|
Lab mode is off by default. `nearly open` shows your own sessions and what
|
|
228
286
|
needs you — the gate, which is what you installed this for. `nearly lab` adds
|
|
229
287
|
the panel for starting agents from the dashboard, which is a different job and
|
package/bin/nearly.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// because the useful thing is what people type first.
|
|
18
18
|
|
|
19
19
|
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, statSync } from 'node:fs';
|
|
20
21
|
import { join, dirname, resolve } from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
|
|
@@ -61,6 +62,12 @@ switch (cmd) {
|
|
|
61
62
|
case 'hook':
|
|
62
63
|
return run(s('hook.mjs'), rest);
|
|
63
64
|
|
|
65
|
+
// internal: what the git pre-push hook runs. It goes through this command
|
|
66
|
+
// rather than a path to the script, so it survives the same upgrades and
|
|
67
|
+
// cache clears the agent hooks do.
|
|
68
|
+
case 'push-record':
|
|
69
|
+
return run(s('push-record.mjs'), rest);
|
|
70
|
+
|
|
64
71
|
case 'record': case 'recap': {
|
|
65
72
|
// Default to the branch you are on, since that is what gets reviewed.
|
|
66
73
|
if (rest.length) return run(s('build-recap.mjs'), rest);
|
|
@@ -86,7 +93,7 @@ switch (cmd) {
|
|
|
86
93
|
return run(s('build-recap.mjs'), ['--voices']);
|
|
87
94
|
|
|
88
95
|
case 'server': {
|
|
89
|
-
console.log(
|
|
96
|
+
console.log(`Nearly on http://127.0.0.1:${process.env.NEARLY_PORT || 47653}`);
|
|
90
97
|
console.log('You do not normally need this: the hooks start it when they need it.');
|
|
91
98
|
return run(join(root, 'server', 'index.mjs'), rest);
|
|
92
99
|
}
|
|
@@ -94,7 +101,7 @@ switch (cmd) {
|
|
|
94
101
|
case 'lab': case 'open': {
|
|
95
102
|
// `open` is the gate: your sessions and what needs you. `lab` adds the
|
|
96
103
|
// panel for starting agents from here, which is a different job.
|
|
97
|
-
const url =
|
|
104
|
+
const url = `http://127.0.0.1:${process.env.NEARLY_PORT || 47653}` + (cmd === 'lab' ? '/?lab=1' : '');
|
|
98
105
|
spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
|
|
99
106
|
[url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
100
107
|
console.log(url);
|
|
@@ -118,6 +125,9 @@ switch (cmd) {
|
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
default:
|
|
128
|
+
// `nearly ~/code/app` — a path, not a command. The "not a git repository"
|
|
129
|
+
// message told people to pass one, and then this said "Unknown command".
|
|
130
|
+
if (existsSync(cmd) && statSync(cmd).isDirectory()) return run(s('attach.mjs'), [cmd, ...rest]);
|
|
121
131
|
console.error(`Unknown command: ${cmd}`);
|
|
122
132
|
console.error('Try: nearly help');
|
|
123
133
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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"
|
|
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"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/scripts/agents.mjs
CHANGED
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { join, resolve } from 'node:path';
|
|
14
|
-
import { ADAPTERS } from '../server/adapters.mjs';
|
|
14
|
+
import { ADAPTERS, OURS_RE } from '../server/adapters.mjs';
|
|
15
15
|
import { detect, installed } from './detect.mjs';
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
17
|
+
// Colour only on a terminal; piped into a file or a CI log it is noise.
|
|
18
|
+
const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
19
|
+
const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
|
|
20
|
+
const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
|
|
21
|
+
const ok = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
|
|
20
22
|
|
|
21
23
|
const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
|
|
22
24
|
const here = new Set(detect(repo).map((d) => d.id));
|
|
@@ -28,7 +30,7 @@ const machine = new Set(installed().map((d) => d.id));
|
|
|
28
30
|
const gated = (a) => {
|
|
29
31
|
const f = join(repo, a.config);
|
|
30
32
|
if (!existsSync(f)) return false;
|
|
31
|
-
try { return
|
|
33
|
+
try { return OURS_RE.test(readFileSync(f, 'utf8')); } catch { return false; }
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
const width = Math.max(...ADAPTERS.map((a) => a.name.length));
|
|
@@ -40,7 +42,9 @@ console.log('');
|
|
|
40
42
|
for (const a of ADAPTERS) {
|
|
41
43
|
const on = gated(a);
|
|
42
44
|
const mark = on ? ok('●') : dim('○');
|
|
43
|
-
|
|
45
|
+
// "Wired", not "gated": a hook being in the config says nothing about whether
|
|
46
|
+
// it runs. `nearly doctor` fires one to find out.
|
|
47
|
+
const state = on ? 'wired here' : here.has(a.id) ? 'used here, not wired' : machine.has(a.id) ? 'installed, unused here' : 'not in use here';
|
|
44
48
|
console.log(` ${mark} ${bold(pad(a.name))} ${pad2(state)} ${dim(a.config)}`);
|
|
45
49
|
}
|
|
46
50
|
function pad2(s) { return s + ' '.repeat(Math.max(0, 22 - s.length)); }
|
package/scripts/attach.mjs
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
// it, and if it cannot start, Claude Code falls back to its own prompts and
|
|
19
19
|
// nothing breaks.
|
|
20
20
|
|
|
21
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, readdirSync, rmdirSync, rmSync } from 'node:fs';
|
|
22
22
|
import { join, resolve, dirname, basename } from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
25
25
|
import { dataRoot, paths } from '../server/paths.mjs';
|
|
26
26
|
import { choose, installed as agentsOnMachine } from './detect.mjs';
|
|
27
|
+
import { installRuntime, hasRuntime, isRuntime, runtimeCommand } from './runtime.mjs';
|
|
27
28
|
import { ADAPTERS } from '../server/adapters.mjs';
|
|
28
29
|
|
|
29
30
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
@@ -76,57 +77,112 @@ const viaNpx = /[\\/]_npx[\\/]/.test(root);
|
|
|
76
77
|
|
|
77
78
|
// `npx nearly-cli` should be the whole of it. An npx run is a temporary
|
|
78
79
|
// download, so hooks pointing at it would pin a version in a directory npm
|
|
79
|
-
// clears, and no fix would ever reach this repo.
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
// clears, and no fix would ever reach this repo.
|
|
81
|
+
//
|
|
82
|
+
// This used to `npm install -g`, which failed silently on every up-to-date
|
|
83
|
+
// Windows machine (Node will not spawn npm.cmd without a shell) and on any Mac
|
|
84
|
+
// whose Node came from the official installer (a root-owned prefix). Each fell
|
|
85
|
+
// back to a pinned npx call in every hook: slower on every tool call, and stuck
|
|
86
|
+
// on that release for good. So it installs into ~/.nearly/runtime instead —
|
|
87
|
+
// somewhere the user always owns. --no-install skips it.
|
|
88
|
+
function installRuntimeOnce() {
|
|
89
|
+
if (!fromPackage || onPath() || hasRuntime() || isRuntime(root)) return hasRuntime();
|
|
90
|
+
if (argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
|
|
91
|
+
process.stdout.write(dim(' Installing nearly so upgrades reach you… '));
|
|
92
|
+
const r = installRuntime(pkgVersion());
|
|
93
|
+
if (r.ok) { console.log('done'); return true; }
|
|
94
|
+
console.log(dim('could not'));
|
|
95
|
+
// The reason, in full. "skipped" in grey is how the last version of this
|
|
96
|
+
// left someone pinned to a broken release without ever knowing why.
|
|
97
|
+
console.log(` ${r.error}`);
|
|
98
|
+
console.log(dim(' Falling back to npx, which is slower on every tool call and will not'));
|
|
99
|
+
console.log(dim(' pick up fixes. Run nearly again here once the problem above is solved.'));
|
|
91
100
|
return false;
|
|
92
101
|
}
|
|
102
|
+
|
|
103
|
+
// Preference, fastest and most durable first:
|
|
104
|
+
// 1. a `nearly` on PATH that is really this tool
|
|
105
|
+
// 2. the runtime install in ~/.nearly/runtime
|
|
106
|
+
// 3. a pinned npx call — works, but slow and never upgrades
|
|
107
|
+
// 4. the checkout's own script, for someone developing this
|
|
93
108
|
let installed = onPath();
|
|
109
|
+
let runtime = hasRuntime();
|
|
94
110
|
const hookCmd = (ev) => installed
|
|
95
|
-
? `nearly hook ${ev}`
|
|
96
|
-
:
|
|
97
|
-
?
|
|
98
|
-
:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
? `nearly hook ${ev}`
|
|
112
|
+
: runtime
|
|
113
|
+
? `${runtimeCommand()} hook ${ev}`
|
|
114
|
+
: fromPackage
|
|
115
|
+
? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
|
|
116
|
+
: `node ${JSON.stringify(HOOK)} ${ev}`;
|
|
117
|
+
// A function, not a value: the install below changes the answer.
|
|
118
|
+
const updateNote = () => (installed || runtime)
|
|
102
119
|
? 'upgrades reach this repo automatically'
|
|
103
120
|
: fromPackage
|
|
104
|
-
? `pinned to v${pkgVersion()} — run nearly again
|
|
121
|
+
? `pinned to v${pkgVersion()} — slow, and fixes will not reach it until you run nearly again`
|
|
105
122
|
: 'running from a checkout — git pull updates it';
|
|
106
123
|
|
|
107
|
-
|
|
124
|
+
// Flags take a value either way: `--agent=cursor` or `--agent cursor`. Only the
|
|
125
|
+
// first worked, so `nearly --agent cursor` read "cursor" as the repo path and
|
|
126
|
+
// said it was not a git repository.
|
|
127
|
+
const VALUED = new Set(['--agent', '--name']);
|
|
128
|
+
const argv = (() => {
|
|
129
|
+
const raw = process.argv.slice(2), out = [];
|
|
130
|
+
for (let i = 0; i < raw.length; i++) {
|
|
131
|
+
if (VALUED.has(raw[i]) && raw[i + 1] !== undefined && !raw[i + 1].startsWith('--')) { out.push(`${raw[i]}=${raw[i + 1]}`); i++; }
|
|
132
|
+
else out.push(raw[i]);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
})();
|
|
136
|
+
const flagValue = (f) => argv.find((a) => a.startsWith(`${f}=`))?.slice(f.length + 1);
|
|
108
137
|
const off = argv.includes('--off') || argv.includes('--detach');
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
// Unattended unless you ask to supervise.
|
|
139
|
+
//
|
|
140
|
+
// Holding a call for a human only works if a human is looking at the dashboard,
|
|
141
|
+
// and nothing tells a new user it exists. So the default used to be: every edit
|
|
142
|
+
// silently waits two minutes and is then refused. That made a Cursor session
|
|
143
|
+
// unable to write a single file, and turned a fifteen-second task into five
|
|
144
|
+
// minutes under auto mode. Two real failures of the same default.
|
|
145
|
+
//
|
|
146
|
+
// Now the never-rules block with nobody present, everything else is done and
|
|
147
|
+
// recorded, and holding for approval is something you turn on while watching.
|
|
148
|
+
const supervise = argv.includes('--supervise');
|
|
149
|
+
const auto = !supervise;
|
|
150
|
+
// The repo is wherever git says its top is. Looking only for a .git folder in
|
|
151
|
+
// the current directory rejected every subfolder, which is where people usually
|
|
152
|
+
// are when they first try something.
|
|
153
|
+
const asked = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
|
|
154
|
+
const repo = (() => {
|
|
155
|
+
try {
|
|
156
|
+
const top = execFileSync('git', ['rev-parse', '--show-toplevel'],
|
|
157
|
+
{ cwd: asked, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
158
|
+
if (!top) return asked;
|
|
159
|
+
// Keep the path as it was typed when it already is the top; git hands back
|
|
160
|
+
// the resolved one (/private/var for /var on macOS), which is the same place
|
|
161
|
+
// spelled in a way nobody wrote.
|
|
162
|
+
return realpathSync.native(asked) === realpathSync.native(top) ? asked : resolve(top);
|
|
163
|
+
} catch { return asked; }
|
|
164
|
+
})();
|
|
165
|
+
const name = (flagValue('--name') ?? basename(repo))
|
|
112
166
|
.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo';
|
|
113
167
|
|
|
114
168
|
// npm is a .cmd shim on Windows and Node will not run one through spawn unless
|
|
115
169
|
// it is named exactly. Without this, installing and upgrading both fail there.
|
|
116
170
|
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
171
|
+
// Colour only on a terminal; piped into a file or a CI log it is noise.
|
|
172
|
+
const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
173
|
+
const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
|
|
174
|
+
const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
|
|
175
|
+
const ok = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
|
|
120
176
|
|
|
121
177
|
if (!existsSync(join(repo, '.git'))) {
|
|
122
|
-
console.error(`${
|
|
178
|
+
console.error(`${asked} is not inside a git repository.`);
|
|
123
179
|
console.error('Run this inside the repo you want recorded, or pass its path.');
|
|
124
180
|
process.exit(1);
|
|
125
181
|
}
|
|
126
182
|
|
|
127
183
|
// Do this before the hooks are written, so they can point at the installed
|
|
128
184
|
// command rather than a temporary npx download.
|
|
129
|
-
if (!off
|
|
185
|
+
if (!off) runtime = installRuntimeOnce() || hasRuntime();
|
|
130
186
|
|
|
131
187
|
// ---------------------------------------------------------------------------
|
|
132
188
|
// Agent hooks
|
|
@@ -143,7 +199,9 @@ if (unknown.length) {
|
|
|
143
199
|
const wired = [];
|
|
144
200
|
const notes = [];
|
|
145
201
|
for (const a of chosen) {
|
|
146
|
-
const cmdFor = (ev) => `${hookCmd(ev)} ${name}`
|
|
202
|
+
const cmdFor = (ev) => `${hookCmd(ev)} ${name}`
|
|
203
|
+
+ (a.id === 'claude-code' ? '' : ` --adapter=${a.id}`)
|
|
204
|
+
+ (auto ? ' --auto' : '');
|
|
147
205
|
try {
|
|
148
206
|
const r = off ? a.uninstall({ repo }) : a.install({ repo, cmdFor, name });
|
|
149
207
|
if (r?.error) { notes.push(`${a.name}: ${r.error}`); continue; }
|
|
@@ -155,6 +213,30 @@ for (const a of chosen) {
|
|
|
155
213
|
}
|
|
156
214
|
}
|
|
157
215
|
|
|
216
|
+
// Leave nothing behind. Turning off used to strip the hooks and keep an empty
|
|
217
|
+
// `{}` file and empty `.cursor/ .agents/ .codex/ .gemini/ .windsurf/` folders —
|
|
218
|
+
// and the next `nearly` read those folders as signs of use and wired all seven
|
|
219
|
+
// agents. Only what is empty goes; nothing of anyone else's is touched, and
|
|
220
|
+
// nothing above the repo.
|
|
221
|
+
if (off) {
|
|
222
|
+
for (const a of ADAPTERS) {
|
|
223
|
+
const file = join(repo, a.config);
|
|
224
|
+
try {
|
|
225
|
+
if (existsSync(file)) {
|
|
226
|
+
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
227
|
+
const hollow = v && typeof v === 'object' && Object.entries(v)
|
|
228
|
+
.filter(([k]) => k !== 'version')
|
|
229
|
+
.every(([, x]) => x == null || (typeof x === 'object' && !Object.keys(x).length));
|
|
230
|
+
if (hollow) rmSync(file);
|
|
231
|
+
}
|
|
232
|
+
for (let dir = dirname(file); dir !== repo && dir.startsWith(repo); dir = dirname(dir)) {
|
|
233
|
+
if (!existsSync(dir) || readdirSync(dir).length) break;
|
|
234
|
+
rmdirSync(dir);
|
|
235
|
+
}
|
|
236
|
+
} catch { /* unreadable or busy: leave it */ }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
158
240
|
// Remember this repo, so the dashboard can offer it before anything has run in
|
|
159
241
|
// it. A list of paths and nothing else: it is a convenience, and it is rebuilt
|
|
160
242
|
// by simply turning Nearly on again.
|
|
@@ -193,7 +275,7 @@ async function checkPort() {
|
|
|
193
275
|
try { mine = realpathSync(root); } catch { /* compare the literal path */ }
|
|
194
276
|
try {
|
|
195
277
|
const { reclaim } = await import('../server/reclaim.mjs');
|
|
196
|
-
return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine });
|
|
278
|
+
return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine, version: pkgVersion() });
|
|
197
279
|
} catch { return null; }
|
|
198
280
|
}
|
|
199
281
|
const port = off ? null : await checkPort();
|
|
@@ -201,8 +283,15 @@ const port = off ? null : await checkPort();
|
|
|
201
283
|
// ---------------------------------------------------------------------------
|
|
202
284
|
// git pre-push hook
|
|
203
285
|
// ---------------------------------------------------------------------------
|
|
286
|
+
const baseCmd = () => installed
|
|
287
|
+
? 'nearly'
|
|
288
|
+
: runtime
|
|
289
|
+
? runtimeCommand()
|
|
290
|
+
: fromPackage
|
|
291
|
+
? `npx -y nearly-cli@${pkgVersion()}`
|
|
292
|
+
: `node ${JSON.stringify(join(root, 'bin', 'nearly.mjs'))}`;
|
|
204
293
|
const push = spawnSync(process.execPath,
|
|
205
|
-
[join(root, 'scripts', 'install-push-hook.mjs'), repo, ...(off ? ['--remove'] : [])],
|
|
294
|
+
[join(root, 'scripts', 'install-push-hook.mjs'), repo, '--cmd', baseCmd(), ...(off ? ['--remove'] : [])],
|
|
206
295
|
{ encoding: 'utf8' });
|
|
207
296
|
|
|
208
297
|
// ---------------------------------------------------------------------------
|
|
@@ -264,12 +353,27 @@ if (off) {
|
|
|
264
353
|
|
|
265
354
|
console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
|
|
266
355
|
console.log('');
|
|
356
|
+
if (supervise) {
|
|
357
|
+
console.log(` ${ok('·')} ${bold('supervised')} ${dim(`— risky calls wait for you at http://127.0.0.1:${PORT}`)}`);
|
|
358
|
+
console.log(` ${dim('keep that page open, or every one of them is refused after two minutes')}`);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(` ${ok('·')} ${dim('destructive commands are blocked; everything else runs and is recorded')}`);
|
|
361
|
+
console.log(` ${dim('to approve risky calls yourself: nearly --supervise')}`);
|
|
362
|
+
}
|
|
267
363
|
for (const a of wired) {
|
|
268
364
|
const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
|
|
269
365
|
console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
|
|
270
366
|
}
|
|
271
367
|
console.log(` ${ok('·')} ${dim(updateNote())}`);
|
|
272
|
-
|
|
368
|
+
if (push.status === 0) {
|
|
369
|
+
console.log(` ${ok('·')} the record is offered when you push`);
|
|
370
|
+
} else {
|
|
371
|
+
// All of it: when a pre-push hook of yours is already there, the lines after
|
|
372
|
+
// the first are the ones that say how to add Nearly to it by hand.
|
|
373
|
+
const said = (push.stderr || '').trim().split('\n').filter(Boolean);
|
|
374
|
+
console.log(` ${ok('·')} ${dim(`pre-push hook skipped: ${said[0] || 'unknown reason'}`)}`);
|
|
375
|
+
for (const line of said.slice(1)) console.log(` ${dim(line)}`);
|
|
376
|
+
}
|
|
273
377
|
if (base) {
|
|
274
378
|
console.log(` ${ok('·')} records publish to ${base}`);
|
|
275
379
|
} else {
|
|
@@ -311,6 +415,8 @@ if (elsewhere.length) {
|
|
|
311
415
|
}
|
|
312
416
|
|
|
313
417
|
console.log('');
|
|
314
|
-
console.log(
|
|
418
|
+
console.log(supervise
|
|
419
|
+
? ` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`
|
|
420
|
+
: ` Now just work. Nothing will wait for you.`);
|
|
315
421
|
console.log(dim(' Nothing to leave running. Turn it off again with --off.'));
|
|
316
422
|
console.log('');
|
package/scripts/build-recap.mjs
CHANGED
|
@@ -100,7 +100,7 @@ function samePath(a, b) {
|
|
|
100
100
|
if (!a || !b) return false;
|
|
101
101
|
const real = (p) => {
|
|
102
102
|
let r;
|
|
103
|
-
try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
|
|
103
|
+
try { r = realpathSync.native(resolve(p)); } catch { r = resolve(p); }
|
|
104
104
|
// Windows spells the same directory more than one way and means the same
|
|
105
105
|
// place. Comparing those as strings loses every session on that machine,
|
|
106
106
|
// the same way /var against /private/var did on this one.
|
|
@@ -220,10 +220,17 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
220
220
|
const undos = events.filter((e) => e.type === 'undo');
|
|
221
221
|
const finals = events.filter((e) => e.type === 'result');
|
|
222
222
|
const texts = events.filter((e) => e.type === 'text');
|
|
223
|
-
|
|
223
|
+
// A held call nobody answered is denied, but nobody decided it. Counting it as
|
|
224
|
+
// a decision made the headline claim "7 decisions by Anuj" and the comment say
|
|
225
|
+
// "refused by the supervisor" about calls no person ever looked at — the one
|
|
226
|
+
// kind of claim a reviewer has no way to check. Recordings from before the
|
|
227
|
+
// server marked timeouts are recognised by their reason.
|
|
228
|
+
const timedOut = (d) => d.scope === 'timeout' || /no human answer/.test(d.why || '');
|
|
229
|
+
const humanDecisions = decisions.filter((d) => d.waitedMs != null && !timedOut(d));
|
|
224
230
|
const denied = decisions.filter((d) => d.decision === 'deny');
|
|
225
231
|
const blocked = decisions.filter((d) => d.tier === 'never');
|
|
226
|
-
|
|
232
|
+
// The wait still happened, answered or not.
|
|
233
|
+
const humanWaitMs = decisions.filter((d) => d.waitedMs != null).reduce((n, d) => n + d.waitedMs, 0);
|
|
227
234
|
const lastResult = finals.at(-1);
|
|
228
235
|
|
|
229
236
|
const name = created?.name ?? id.slice(0, 8);
|
|
@@ -241,13 +248,23 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
241
248
|
if (undos.length) headlineBits.push(`${plural(undos.length, 'turn')} rolled back`);
|
|
242
249
|
if (humanDecisions.length) headlineBits.push(plural(humanDecisions.length, 'decision') + V(` by ${AUTHOR}`, ' from you'));
|
|
243
250
|
if (!denied.length && !undos.length && checkpoints.length) headlineBits.push(plural(checkpoints.length, 'turn') + (attached ? '' : ' committed'));
|
|
251
|
+
|
|
252
|
+
// An unattended run is the one case where the absence of human decisions is
|
|
253
|
+
// itself the fact. Leaving it implied reads as "nothing needed approving"
|
|
254
|
+
// when what happened is that nobody was asked.
|
|
255
|
+
const unattended = decisions.some((d) => d.scope === 'auto');
|
|
244
256
|
scenes.push({
|
|
245
257
|
kind: 'cover',
|
|
246
258
|
title: headlineBits.length ? headlineBits.join(', ') : 'A session with nothing to flag',
|
|
247
259
|
runs: sbRuns,
|
|
260
|
+
unattended,
|
|
248
261
|
orient: forReviewer
|
|
249
|
-
?
|
|
250
|
-
|
|
262
|
+
? (unattended
|
|
263
|
+
? `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}, with nobody watching it work. Nothing here was approved by a person — the rules did the stopping. This is the only account of what it did.`
|
|
264
|
+
: `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}. This is what happened while it was writing it — including the things it was stopped from doing, which the diff cannot show you.`)
|
|
265
|
+
: (unattended
|
|
266
|
+
? `Everything your agent did while you were not watching, including what the rules stopped it from doing.`
|
|
267
|
+
: `Everything your agent did in this session, including what you stopped it from doing.`),
|
|
251
268
|
repo: worktree ? basename(worktree) : null,
|
|
252
269
|
branch: created?.branch || null,
|
|
253
270
|
stats: [
|
|
@@ -311,6 +328,8 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
311
328
|
let narration;
|
|
312
329
|
if (e.tier === 'never') {
|
|
313
330
|
narration = `${asked.replace('It asked', 'It tried')}. A never rule blocked it before ${V('anyone', 'you')} saw it.`;
|
|
331
|
+
} else if (timedOut(e)) {
|
|
332
|
+
narration = `${asked}. Nobody answered in ${w} seconds, so it was refused rather than allowed. The agent carried on without it.`;
|
|
314
333
|
} else if (e.decision === 'allow') {
|
|
315
334
|
narration = `${asked}. ${SUP} allowed it${e.scope === 'always' ? ' as a rule' : ''} after ${w} seconds.`;
|
|
316
335
|
} else {
|
|
@@ -382,7 +401,7 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
382
401
|
const notDone = denied.map((d) => {
|
|
383
402
|
const ask = asks.find((a) => a.id === d.id);
|
|
384
403
|
const input = ask?.input ?? d.input ?? {};
|
|
385
|
-
return { tool: d.tool, what: short(describeInput(d.tool, input), 90), by: d.tier === 'never' ? 'policy' : 'you' };
|
|
404
|
+
return { tool: d.tool, what: short(describeInput(d.tool, input), 90), by: d.tier === 'never' ? 'policy' : timedOut(d) ? 'timeout' : 'you' };
|
|
386
405
|
});
|
|
387
406
|
const head = canGit && !created?.attached ? git(worktree, ['rev-parse', '--short', 'HEAD']) : null;
|
|
388
407
|
scenes.push({
|
|
@@ -391,11 +410,13 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
391
410
|
narration: (() => {
|
|
392
411
|
let n = `The agent reported: ${short(finalText, 150)}`;
|
|
393
412
|
if (!notDone.length) return n;
|
|
394
|
-
const byHuman = notDone.filter((x) => x.by
|
|
395
|
-
const byPolicy = notDone.
|
|
413
|
+
const byHuman = notDone.filter((x) => x.by === 'you').length;
|
|
414
|
+
const byPolicy = notDone.filter((x) => x.by === 'policy').length;
|
|
415
|
+
const byTimeout = notDone.filter((x) => x.by === 'timeout').length;
|
|
396
416
|
const parts = [];
|
|
397
417
|
if (byHuman) parts.push(`${byHuman} ${byHuman === 1 ? 'was' : 'were'} refused by ${sup}`);
|
|
398
418
|
if (byPolicy) parts.push(`${byPolicy} ${byPolicy === 1 ? 'was' : 'were'} blocked by policy before anyone saw ${byPolicy === 1 ? 'it' : 'them'}`);
|
|
419
|
+
if (byTimeout) parts.push(`${byTimeout} ${byTimeout === 1 ? 'was' : 'were'} refused because nobody answered`);
|
|
399
420
|
return `${n} Read that with a caveat: ${plural(notDone.length, 'requested step')} never ran. ${parts.join(', and ')}.`;
|
|
400
421
|
})(),
|
|
401
422
|
});
|
|
@@ -650,10 +671,28 @@ const slug = BRANCH ? `${safe(sb.name)}--${safe(BRANCH)}` : `${sb.name}-${sb.id.
|
|
|
650
671
|
const jsonPath = join(storyDirOut, `${slug}.json`);
|
|
651
672
|
writeFileSync(jsonPath, JSON.stringify({ ...sb, scenes: sb.scenes.map(({ audio, ...s }) => s) }, null, 2));
|
|
652
673
|
|
|
674
|
+
// Everything in a record came from an agent, so everything is hostile until
|
|
675
|
+
// proven otherwise.
|
|
676
|
+
//
|
|
677
|
+
// This used to be `.replace('__RECAP__', json)`. With a string as the
|
|
678
|
+
// replacement, JavaScript reads `$&`, `$\``, `$'` in it as instructions — so a
|
|
679
|
+
// command containing `$\`` pasted the page's whole <head> into the record's
|
|
680
|
+
// data, broke out of the script block, and ran text from the agent's command as
|
|
681
|
+
// code in the reviewer's browser. Reproduced: the tab retitled itself. A
|
|
682
|
+
// function as the replacement is taken literally.
|
|
683
|
+
//
|
|
684
|
+
// And inside a <script>, escaping `</script>` is not enough: `<!--` and
|
|
685
|
+
// `<script` change how the HTML parser reads the block. Encoding every `<`, `>`
|
|
686
|
+
// and `&` as \u escapes leaves nothing for it to act on, and JSON.parse reads
|
|
687
|
+
// them back unchanged. U+2028/2029 are line terminators to older JavaScript.
|
|
688
|
+
const scriptSafe = (value) => JSON.stringify(value)
|
|
689
|
+
.replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
|
|
690
|
+
.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
691
|
+
const htmlSafe = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
653
692
|
const html = readFileSync(templatePath, 'utf8')
|
|
654
|
-
.replace('__RECAP__',
|
|
655
|
-
.replaceAll('__TITLE__', `${sb.name} · ${sb.scenes[0].title}`)
|
|
656
|
-
.replaceAll('__DESC__', `Recap of a Nearly session: ${sb.scenes[0].narration}`);
|
|
693
|
+
.replace('__RECAP__', () => scriptSafe(sb))
|
|
694
|
+
.replaceAll('__TITLE__', () => htmlSafe(`${sb.name} · ${sb.scenes[0].title}`))
|
|
695
|
+
.replaceAll('__DESC__', () => htmlSafe(`Recap of a Nearly session: ${sb.scenes[0].narration}`));
|
|
657
696
|
const outPath = join(outDir, `${slug}.html`);
|
|
658
697
|
writeFileSync(outPath, html);
|
|
659
698
|
|
package/scripts/detect.mjs
CHANGED
|
@@ -63,7 +63,8 @@ export function installed() {
|
|
|
63
63
|
// --agent=cursor,gemini exactly these
|
|
64
64
|
// --agent=all every adapter there is
|
|
65
65
|
export function choose(repo, argv = []) {
|
|
66
|
-
const
|
|
66
|
+
const at = argv.indexOf('--agent');
|
|
67
|
+
const flag = argv.find((a) => a.startsWith('--agent=')) ?? (at !== -1 && argv[at + 1] ? `--agent=${argv[at + 1]}` : undefined);
|
|
67
68
|
if (flag) {
|
|
68
69
|
const want = flag.slice('--agent='.length).split(',').map((s) => s.trim()).filter(Boolean);
|
|
69
70
|
if (want.includes('all')) return { chosen: ADAPTERS, unknown: [] };
|