nearly-cli 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/build-recap.mjs +8 -1
- package/scripts/doctor.mjs +63 -3
- package/scripts/post-recap.mjs +7 -2
- package/server/adapters.mjs +12 -5
- package/server/index.mjs +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/build-recap.mjs
CHANGED
|
@@ -98,7 +98,14 @@ function parseRecording(path) {
|
|
|
98
98
|
// belongs to nobody.
|
|
99
99
|
function samePath(a, b) {
|
|
100
100
|
if (!a || !b) return false;
|
|
101
|
-
const real = (p) => {
|
|
101
|
+
const real = (p) => {
|
|
102
|
+
let r;
|
|
103
|
+
try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
|
|
104
|
+
// Windows spells the same directory more than one way and means the same
|
|
105
|
+
// place. Comparing those as strings loses every session on that machine,
|
|
106
|
+
// the same way /var against /private/var did on this one.
|
|
107
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
108
|
+
};
|
|
102
109
|
return real(a) === real(b);
|
|
103
110
|
}
|
|
104
111
|
|
package/scripts/doctor.mjs
CHANGED
|
@@ -61,6 +61,50 @@ const gated = ADAPTERS.filter((a) => {
|
|
|
61
61
|
if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
|
|
62
62
|
else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
|
|
63
63
|
|
|
64
|
+
// Configured is not the same as working, and the difference is invisible.
|
|
65
|
+
// Hooks fail open on purpose — a broken one must never wedge an agent — so a
|
|
66
|
+
// command that cannot be found produces silence, and silence looks exactly like
|
|
67
|
+
// a session nobody ran. Everything above can be green while nothing is gated.
|
|
68
|
+
//
|
|
69
|
+
// So run the hook this repo actually has, the way the agent runs it, and see
|
|
70
|
+
// whether an answer comes back.
|
|
71
|
+
if (gated.length) {
|
|
72
|
+
const cc = gated.find((a) => a.id === 'claude-code') || gated[0];
|
|
73
|
+
let cmd = null;
|
|
74
|
+
try {
|
|
75
|
+
const cfg = JSON.parse(readFileSync(join(repo, cc.config), 'utf8'));
|
|
76
|
+
const walk = (o) => {
|
|
77
|
+
if (!o || typeof o !== 'object') return;
|
|
78
|
+
if (typeof o.command === 'string' && /nearly/i.test(o.command) && /pre-tool/.test(o.command)) cmd = o.command;
|
|
79
|
+
for (const v of Object.values(o)) walk(v);
|
|
80
|
+
};
|
|
81
|
+
walk(cfg);
|
|
82
|
+
} catch { /* unreadable config */ }
|
|
83
|
+
|
|
84
|
+
if (!cmd) {
|
|
85
|
+
say(null, 'hooks actually fire', 'could not find the pre-tool hook to try');
|
|
86
|
+
} else {
|
|
87
|
+
const probe = JSON.stringify({
|
|
88
|
+
session_id: `nearly-doctor-${Date.now()}`, cwd: repo,
|
|
89
|
+
hook_event_name: 'PreToolUse', tool_name: 'Read',
|
|
90
|
+
tool_input: { file_path: join(repo, 'nearly-doctor-probe') }, tool_use_id: 'doctor',
|
|
91
|
+
});
|
|
92
|
+
// shell: true because the agent runs these through a shell, and on Windows
|
|
93
|
+
// the installed command is a .cmd that will not spawn any other way.
|
|
94
|
+
const r = spawnSync(cmd, { input: probe, shell: true, encoding: 'utf8', timeout: 30_000 });
|
|
95
|
+
const decided = /permissionDecision|"decision"|"permission"/.test(r.stdout || '');
|
|
96
|
+
if (decided) {
|
|
97
|
+
say(true, 'hooks actually fire', 'the gate answered a test call');
|
|
98
|
+
} else {
|
|
99
|
+
const why = (r.error && r.error.message)
|
|
100
|
+
|| (r.stderr || '').trim().split('\n')[0]
|
|
101
|
+
|| (r.status !== 0 ? `the hook command exited ${r.status}` : 'the hook ran but answered nothing');
|
|
102
|
+
say(false, 'hooks actually fire', why,
|
|
103
|
+
`the hook command in ${cc.config} does not work here, so nothing is gated and nothing is recorded — check that \`nearly\` runs in a plain shell, then re-run \`nearly\` to rewrite the hooks`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
// 3 — the server, and whether it is this build
|
|
65
109
|
let health = null;
|
|
66
110
|
try {
|
|
@@ -73,15 +117,31 @@ if (!health) {
|
|
|
73
117
|
let mine = root;
|
|
74
118
|
try { mine = realpathSync(root); } catch { /* compare literally */ }
|
|
75
119
|
const same = health.root === mine;
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
let ours = null;
|
|
121
|
+
try { ours = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { /* unknown */ }
|
|
122
|
+
if (same) {
|
|
123
|
+
say(true, 'server', `v${health.version}`);
|
|
124
|
+
} else if (health.version && health.version === ours) {
|
|
125
|
+
// Running this through npx gives a throwaway directory every time, so the
|
|
126
|
+
// paths differ even when the build is identical. Saying "a different
|
|
127
|
+
// install" there is true and useless; the version is what anyone cares
|
|
128
|
+
// about, and a matching one is holding nothing back.
|
|
129
|
+
say(null, 'server', `v${health.version} from another copy of the same version — nothing stale about it`);
|
|
130
|
+
} else {
|
|
131
|
+
say(false, 'server', `an older build is answering${health.version ? ` (v${health.version})` : ''}: ${health.root || 'it does not say where it lives'}`,
|
|
132
|
+
'run `nearly` here — it closes the older server holding the port');
|
|
133
|
+
}
|
|
78
134
|
}
|
|
79
135
|
|
|
80
136
|
// 4 — recordings for this branch, matched the way the record builder matches
|
|
81
137
|
// them, so this cannot disagree with it.
|
|
82
138
|
const recDir = paths.recordings();
|
|
83
139
|
let runs = 0, otherBranches = new Set();
|
|
84
|
-
const real = (p) => {
|
|
140
|
+
const real = (p) => {
|
|
141
|
+
let r;
|
|
142
|
+
try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
|
|
143
|
+
return process.platform === 'win32' ? r.toLowerCase() : r; // same place, spelled differently
|
|
144
|
+
};
|
|
85
145
|
try {
|
|
86
146
|
for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
|
|
87
147
|
let created = null;
|
package/scripts/post-recap.mjs
CHANGED
|
@@ -49,7 +49,12 @@ lines.push(sb.runs > 1
|
|
|
49
49
|
lines.push('');
|
|
50
50
|
lines.push(urlBase
|
|
51
51
|
? `**[Watch the record (${mmss(sb.totalS)})](${urlBase}/${slug}.html)** · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`
|
|
52
|
-
|
|
52
|
+
// No host configured. Everything a reviewer needs to act on is in this
|
|
53
|
+
// comment already — what was refused, and what the diff therefore cannot show
|
|
54
|
+
// them. Only the player is missing, so say where it is honestly rather than
|
|
55
|
+
// naming a path from the developer's own checkout that means nothing to
|
|
56
|
+
// anybody who installed this.
|
|
57
|
+
: `${sb.runs > 1 ? `${sb.runs} agent sessions` : `Agent \`${sb.name}\``} · ${sb.model} · ${sb.date} · ${mmss(sb.totalS)} recording, kept on the author's machine`);
|
|
53
58
|
lines.push('');
|
|
54
59
|
if (outcome?.notDone?.length) {
|
|
55
60
|
lines.push(`> **${outcome.notDone.length} thing${outcome.notDone.length > 1 ? 's' : ''} the agent wanted to do did not happen.** The diff cannot show you this.`);
|
|
@@ -67,7 +72,7 @@ sb.scenes.forEach((s, i) => { lines.push(`${i + 1}. **${s.kind}** — ${s.narrat
|
|
|
67
72
|
lines.push('');
|
|
68
73
|
lines.push('</details>');
|
|
69
74
|
lines.push('');
|
|
70
|
-
lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}</sub>`);
|
|
75
|
+
lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}${urlBase ? '' : ' The narrated version is not published anywhere; `nearly publish` puts it on GitHub Pages.'}</sub>`);
|
|
71
76
|
// A hidden marker so we can find our own comment again on the next push and
|
|
72
77
|
// edit it, instead of stacking a new one on every push until nobody reads any.
|
|
73
78
|
// Deliberately carries no product name. This string is how a comment is
|
package/server/adapters.mjs
CHANGED
|
@@ -362,11 +362,18 @@ export const ADAPTERS = [
|
|
|
362
362
|
const cfg = { version: 1, hooks: {} };
|
|
363
363
|
for (const [their, ours] of Object.entries(this.events)) {
|
|
364
364
|
const run = cmdFor(ours);
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
365
|
+
// Two products read this file and disagree about the key. Copilot CLI
|
|
366
|
+
// takes `bash` and `powershell`; VS Code's agent mode takes `windows`,
|
|
367
|
+
// `linux` and `osx` as per-platform overrides and does not document the
|
|
368
|
+
// other two at all. Writing both sets costs a few bytes. Guessing wrong
|
|
369
|
+
// means the hook never runs, and a hook that never runs looks exactly
|
|
370
|
+
// like a week in which nobody did any work.
|
|
371
|
+
cfg.hooks[their] = [{
|
|
372
|
+
type: 'command', command: run,
|
|
373
|
+
bash: run, powershell: run,
|
|
374
|
+
windows: run, linux: run, osx: run,
|
|
375
|
+
timeoutSec: holdFor(ours), timeout: holdFor(ours),
|
|
376
|
+
}];
|
|
370
377
|
}
|
|
371
378
|
writeJson(file, cfg); // our own file; nobody else's entries to keep
|
|
372
379
|
return { file };
|
package/server/index.mjs
CHANGED
|
@@ -351,7 +351,12 @@ function decide(sid, id, decision, why, scope = 'once') {
|
|
|
351
351
|
clearTimeout(p.timer);
|
|
352
352
|
s.pending.delete(id);
|
|
353
353
|
if (scope === 'always') rules.set(p.key, decision === 'allow' ? 'log' : 'never');
|
|
354
|
-
|
|
354
|
+
// One call can arrive down more than one hook — VS Code reads both
|
|
355
|
+
// .claude/settings.local.json and .github/hooks/*.json, so a repo wired for
|
|
356
|
+
// Claude Code and Copilot fires twice for the same tool_use_id. Everyone who
|
|
357
|
+
// asked gets the same answer; answering only the last one left the first hook
|
|
358
|
+
// hanging until the agent's own timeout, which looks like the agent freezing.
|
|
359
|
+
for (const r of p.responders) r(decision, why);
|
|
355
360
|
record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
|
|
356
361
|
if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
|
|
357
362
|
broadcast({ type: 'session-state', session: sid, state: s.state });
|
|
@@ -440,7 +445,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
440
445
|
// else gets to decide by not answering.
|
|
441
446
|
const asked = Number(url.searchParams.get('hold')) || 0;
|
|
442
447
|
const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
|
|
443
|
-
|
|
448
|
+
// Same call, second hook: join the question already being asked rather
|
|
449
|
+
// than replacing it, so the person is not asked twice about one thing.
|
|
450
|
+
const already = s.pending.get(id);
|
|
451
|
+
if (already) { already.responders.push(respond); return; }
|
|
452
|
+
const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, responders: [respond] };
|
|
444
453
|
item.timer = setTimeout(() => decide(sid, id, 'deny',
|
|
445
454
|
`no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
|
|
446
455
|
s.pending.set(id, item);
|