nearly-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +274 -0
- package/STUDY.md +101 -0
- package/bin/nearly.mjs +108 -0
- package/package.json +39 -0
- package/scripts/attach.mjs +195 -0
- package/scripts/build-recap.mjs +655 -0
- package/scripts/hook.mjs +79 -0
- package/scripts/install-push-hook.mjs +87 -0
- package/scripts/post-recap.mjs +124 -0
- package/scripts/publish-pages.mjs +141 -0
- package/scripts/push-record.mjs +126 -0
- package/scripts/update-check.mjs +116 -0
- package/server/index.mjs +538 -0
- package/server/policy.mjs +61 -0
- package/ui/index.html +522 -0
- package/ui/recap.template.html +449 -0
package/scripts/hook.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One Claude Code hook event, forwarded to the Nearly.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/hook.mjs <event> <repo-name>
|
|
5
|
+
//
|
|
6
|
+
// Claude Code writes the event as JSON on stdin and reads our answer from
|
|
7
|
+
// stdout. We sit in between so the server does not have to be running before
|
|
8
|
+
// you start work: if nothing is listening, this starts it, waits for it, and
|
|
9
|
+
// forwards. Nobody has to remember a terminal.
|
|
10
|
+
//
|
|
11
|
+
// Two rules this file exists to honour:
|
|
12
|
+
//
|
|
13
|
+
// 1. Never break someone's session. If the server cannot be reached or
|
|
14
|
+
// started, print nothing and exit 0. Claude Code then falls back to its own
|
|
15
|
+
// permission prompts, which is worse than being recorded but better than
|
|
16
|
+
// being stuck.
|
|
17
|
+
// 2. Never take longer than it has to. The health check is a few milliseconds
|
|
18
|
+
// on the common path, where the server is already up.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const HOST = '127.0.0.1';
|
|
25
|
+
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
26
|
+
const BASE = `http://${HOST}:${PORT}`;
|
|
27
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
const [, , event, name = 'repo'] = process.argv;
|
|
30
|
+
if (!event) process.exit(0);
|
|
31
|
+
|
|
32
|
+
const body = await new Promise((r) => {
|
|
33
|
+
let s = '';
|
|
34
|
+
process.stdin.setEncoding('utf8');
|
|
35
|
+
process.stdin.on('data', (d) => (s += d));
|
|
36
|
+
process.stdin.on('end', () => r(s));
|
|
37
|
+
process.stdin.on('error', () => r(''));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function up(ms = 400) {
|
|
41
|
+
try {
|
|
42
|
+
const c = AbortSignal.timeout(ms);
|
|
43
|
+
const r = await fetch(`${BASE}/health`, { signal: c });
|
|
44
|
+
return r.ok;
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function start() {
|
|
49
|
+
const child = spawn(process.execPath, [join(root, 'server', 'index.mjs')], {
|
|
50
|
+
cwd: root, detached: true, stdio: 'ignore',
|
|
51
|
+
});
|
|
52
|
+
child.unref();
|
|
53
|
+
// Two seconds is generous for a dependency-free server binding one port.
|
|
54
|
+
for (let i = 0; i < 20; i++) {
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
if (await up(200)) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!(await up()) && !(await start())) process.exit(0); // fail open, silently
|
|
62
|
+
|
|
63
|
+
// PreToolUse can hold for as long as the server is willing to wait for a human.
|
|
64
|
+
// Everything else should be quick; keep it short so a wedged endpoint cannot
|
|
65
|
+
// stall the agent.
|
|
66
|
+
const budget = event === 'pre-tool' ? 600_000 : 15_000;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
body: body || '{}',
|
|
73
|
+
signal: AbortSignal.timeout(budget),
|
|
74
|
+
});
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
if (text && text !== '{}') process.stdout.write(text);
|
|
77
|
+
} catch { /* fail open */ }
|
|
78
|
+
|
|
79
|
+
process.exit(0);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Install a git pre-push hook that builds the branch's session record and
|
|
2
|
+
// offers to post it to the pull request.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/install-push-hook.mjs <repo-path> [--remove]
|
|
5
|
+
//
|
|
6
|
+
// Pushing is the moment your work stops being yours and becomes someone else's
|
|
7
|
+
// to review, so it is the right moment to hand over the record. The hook:
|
|
8
|
+
//
|
|
9
|
+
// 1. builds one record for the branch being pushed, merging every session
|
|
10
|
+
// 2. prints what it found, including anything that was refused
|
|
11
|
+
// 3. asks whether to post it, reading your answer from the terminal
|
|
12
|
+
// 4. gets out of the way
|
|
13
|
+
//
|
|
14
|
+
// It never blocks a push. If the record cannot be built, or you say no, or
|
|
15
|
+
// anything at all goes wrong, the push proceeds and the hook exits 0. Posting
|
|
16
|
+
// is always your explicit "y" — a record of what you refused is more revealing
|
|
17
|
+
// than a diff, and software should not publish that on your behalf.
|
|
18
|
+
|
|
19
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, chmodSync } from 'node:fs';
|
|
20
|
+
import { join, resolve, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const repo = resolve(argv.find((a) => !a.startsWith('--')) || '.');
|
|
26
|
+
const remove = argv.includes('--remove');
|
|
27
|
+
|
|
28
|
+
if (!existsSync(join(repo, '.git'))) {
|
|
29
|
+
console.error(`${repo} is not a git repository`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
// Stable across renames on purpose: this string is how a hook is identified as
|
|
33
|
+
// ours years from now, so it must never carry the product name.
|
|
34
|
+
const MARKER = 'x-session-record-hook';
|
|
35
|
+
const hooksDir = join(repo, '.git', 'hooks');
|
|
36
|
+
const hookPath = join(hooksDir, 'pre-push');
|
|
37
|
+
|
|
38
|
+
if (remove) {
|
|
39
|
+
if (existsSync(hookPath)) { rmSync(hookPath); console.log(`Removed ${hookPath}`); }
|
|
40
|
+
else console.log('No pre-push hook to remove.');
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (existsSync(hookPath)) {
|
|
45
|
+
const existing = readFileSync(hookPath, 'utf8');
|
|
46
|
+
// Recognise it by a marker that does not change when the product is renamed,
|
|
47
|
+
// and still recognise hooks written before this file existed.
|
|
48
|
+
const mine = existing.includes(MARKER) || /control-room|nearly/.test(existing);
|
|
49
|
+
if (!mine) {
|
|
50
|
+
console.error(`${hookPath} already exists and was not written by the Nearly.`);
|
|
51
|
+
console.error('Refusing to overwrite it. Move it aside, or add this line to it yourself:');
|
|
52
|
+
console.error(` node ${join(root, 'scripts', 'push-record.mjs')} "${repo}" || true`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
58
|
+
writeFileSync(hookPath, `#!/bin/sh
|
|
59
|
+
# ${MARKER}
|
|
60
|
+
# nearly: hand the session record over at push time.
|
|
61
|
+
# Never blocks the push; "exit 0" at the end is the whole safety story.
|
|
62
|
+
#
|
|
63
|
+
# git gives a hook no terminal of its own, so borrow the user's when there is
|
|
64
|
+
# one. Scripted and CI pushes have no controlling terminal: run anyway, print
|
|
65
|
+
# nothing to ask, and post nothing.
|
|
66
|
+
#
|
|
67
|
+
# The test has to be an actual open. /dev/tty always exists and is always
|
|
68
|
+
# readable and writable by its permission bits; opening it is what fails, with
|
|
69
|
+
# ENXIO, when no terminal is attached. The open has to happen inside a subshell
|
|
70
|
+
# too: a failed redirection is reported by the shell itself, so redirecting the
|
|
71
|
+
# command's stderr does not silence it, but redirecting the subshell's does.
|
|
72
|
+
CR="${join(root, 'scripts', 'push-record.mjs')}"
|
|
73
|
+
if (: >/dev/tty) 2>/dev/null; then
|
|
74
|
+
node "$CR" "${repo}" </dev/tty >/dev/tty 2>&1 || true
|
|
75
|
+
else
|
|
76
|
+
NEARLY_NO_TTY=1 node "$CR" "${repo}" || true
|
|
77
|
+
fi
|
|
78
|
+
exit 0
|
|
79
|
+
`);
|
|
80
|
+
chmodSync(hookPath, 0o755);
|
|
81
|
+
|
|
82
|
+
console.log(`Installed ${hookPath}`);
|
|
83
|
+
console.log('');
|
|
84
|
+
console.log('Next push on this repo will build the branch record and ask before posting.');
|
|
85
|
+
console.log('Set NEARLY_URL_BASE so the comment can link to the hosted page, e.g.');
|
|
86
|
+
console.log(' export NEARLY_URL_BASE=https://<user>.github.io/<repo>/recaps');
|
|
87
|
+
console.log('Remove it again with: node scripts/install-push-hook.mjs "' + repo + '" --remove');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Post a session's recap to the pull request for its branch, as a comment.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/post-recap.mjs <session-id | latest> [--url-base https://you.github.io/nearly/recaps] [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// Uses the GitHub CLI (`gh pr comment`) in the repo the session ran in, so it
|
|
6
|
+
// works with whatever account gh is logged in as. Nothing is uploaded: the
|
|
7
|
+
// comment carries the computed summary and every narration line as text, plus
|
|
8
|
+
// a link to the record page when --url-base (or NEARLY_URL_BASE) says where the
|
|
9
|
+
// ui/records folder is hosted. Without a base URL the comment says where the
|
|
10
|
+
// file lives locally. --dry-run prints the comment and posts nothing.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const dry = argv.includes('--dry-run');
|
|
21
|
+
const ubIdx = argv.indexOf('--url-base');
|
|
22
|
+
const urlBase = (ubIdx !== -1 ? argv[ubIdx + 1] : process.env.NEARLY_URL_BASE || '').replace(/\/$/, '');
|
|
23
|
+
const target = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--url-base') || 'latest';
|
|
24
|
+
|
|
25
|
+
const dir = join(root, 'records');
|
|
26
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
27
|
+
if (!files.length) { console.error('no storyboards in records/. Run build-recap first.'); process.exit(1); }
|
|
28
|
+
let file;
|
|
29
|
+
if (target === 'latest') {
|
|
30
|
+
file = files.map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m)[0].f;
|
|
31
|
+
} else {
|
|
32
|
+
file = files.find((f) => f === `${target}.json`) // exact slug, e.g. repo--branch
|
|
33
|
+
|| files.find((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')).id.startsWith(target)) // session id
|
|
34
|
+
|| files.find((f) => f.includes(target)); // loose match, last resort
|
|
35
|
+
}
|
|
36
|
+
if (!file) { console.error(`no storyboard for ${target}`); process.exit(1); }
|
|
37
|
+
|
|
38
|
+
const sb = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
39
|
+
const slug = file.replace(/\.json$/, '');
|
|
40
|
+
const cover = sb.scenes.find((s) => s.kind === 'cover');
|
|
41
|
+
const outcome = sb.scenes.find((s) => s.kind === 'outcome');
|
|
42
|
+
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
|
43
|
+
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(sb.runs > 1
|
|
46
|
+
? `### Session record for \`${sb.branch}\`: ${cover.title}`
|
|
47
|
+
: `### Session record: ${cover.title}`);
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(urlBase
|
|
50
|
+
? `**[Watch the record (${mmss(sb.totalS)})](${urlBase}/${slug}.html)** · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`
|
|
51
|
+
: `Record: \`ui/records/${slug}.html\` in the Nearly checkout (${mmss(sb.totalS)}, not hosted yet) · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
if (outcome?.notDone?.length) {
|
|
54
|
+
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.`);
|
|
55
|
+
lines.push('>');
|
|
56
|
+
for (const n of outcome.notDone) lines.push(`> - \`${n.tool}\` · \`${n.what}\` — ${n.by === 'policy' ? 'blocked by policy' : 'refused by the supervisor'}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
lines.push('| ' + cover.stats.map(([k]) => k).join(' | ') + ' |');
|
|
60
|
+
lines.push('|' + cover.stats.map(() => '---').join('|') + '|');
|
|
61
|
+
lines.push('| ' + cover.stats.map(([, v]) => v).join(' | ') + ' |');
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('<details><summary>Scene by scene</summary>');
|
|
64
|
+
lines.push('');
|
|
65
|
+
sb.scenes.forEach((s, i) => { lines.push(`${i + 1}. **${s.kind}** — ${s.narration}`); });
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('</details>');
|
|
68
|
+
lines.push('');
|
|
69
|
+
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>`);
|
|
70
|
+
// A hidden marker so we can find our own comment again on the next push and
|
|
71
|
+
// edit it, instead of stacking a new one on every push until nobody reads any.
|
|
72
|
+
// Deliberately carries no product name. This string is how a comment is
|
|
73
|
+
// recognised as ours on every future push, so renaming the project must not
|
|
74
|
+
// orphan every comment already posted. LEGACY covers ones posted before this.
|
|
75
|
+
const MARKER = '<!-- x-session-record -->';
|
|
76
|
+
const LEGACY = ['<!-- nearly:session-record -->', '<!-- control-room:session-record -->'];
|
|
77
|
+
const body = `${MARKER}\n${lines.join('\n')}`;
|
|
78
|
+
|
|
79
|
+
if (dry) { console.log(body); process.exit(0); }
|
|
80
|
+
|
|
81
|
+
const cwd = sb.cwd;
|
|
82
|
+
if (!cwd) { console.error('storyboard has no repo path; cannot find the pull request'); process.exit(1); }
|
|
83
|
+
|
|
84
|
+
const gh = (args, opts = {}) => spawnSync('gh', args, { cwd, encoding: 'utf8', ...opts });
|
|
85
|
+
|
|
86
|
+
// Which pull request, and in which repository.
|
|
87
|
+
//
|
|
88
|
+
// Ask for the record's own branch rather than whatever happens to be checked
|
|
89
|
+
// out. You should be able to hand over a branch's record from anywhere in the
|
|
90
|
+
// repo, and days after you moved on from it.
|
|
91
|
+
const view = gh(['pr', 'view', ...(sb.branch ? [sb.branch] : []), '--json', 'number,url']);
|
|
92
|
+
if (view.status !== 0) {
|
|
93
|
+
console.error(`no open pull request for ${sb.branch ? `"${sb.branch}"` : 'this branch'} in ${cwd}`);
|
|
94
|
+
console.error((view.stderr || view.stdout || '').trim().split('\n')[0]);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const { number, url: prUrl } = JSON.parse(view.stdout);
|
|
98
|
+
const repoView = gh(['repo', 'view', '--json', 'nameWithOwner']);
|
|
99
|
+
const nwo = JSON.parse(repoView.stdout || '{}').nameWithOwner;
|
|
100
|
+
if (!nwo) { console.error('could not identify the repository'); process.exit(1); }
|
|
101
|
+
|
|
102
|
+
const tmp = join(tmpdir(), `recap-comment-${slug}.md`);
|
|
103
|
+
writeFileSync(tmp, body);
|
|
104
|
+
|
|
105
|
+
// Already posted one? Edit it. A branch gets pushed many times, and the reviewer
|
|
106
|
+
// should see the current state, not a stack of stale records.
|
|
107
|
+
const anyMarker = [MARKER, ...LEGACY].map((m) => `(.body | contains("${m}"))`).join(' or ');
|
|
108
|
+
const mine = gh(['api', `repos/${nwo}/issues/${number}/comments`, '--paginate',
|
|
109
|
+
'--jq', `[.[] | select(${anyMarker}) | .id] | first`]);
|
|
110
|
+
const existing = (mine.stdout || '').trim();
|
|
111
|
+
|
|
112
|
+
let r;
|
|
113
|
+
if (existing && existing !== 'null') {
|
|
114
|
+
r = gh(['api', '-X', 'PATCH', `repos/${nwo}/issues/comments/${existing}`,
|
|
115
|
+
'-F', `body=@${tmp}`, '--jq', '.html_url']);
|
|
116
|
+
if (r.status === 0) console.log(`updated ${(r.stdout || '').trim() || prUrl}`);
|
|
117
|
+
} else {
|
|
118
|
+
r = gh(['pr', 'comment', String(number), '--body-file', tmp]);
|
|
119
|
+
if (r.status === 0) console.log(`posted ${(r.stdout || '').trim() || prUrl}`);
|
|
120
|
+
}
|
|
121
|
+
if (r.status !== 0) {
|
|
122
|
+
console.error(`could not ${existing && existing !== 'null' ? 'update' : 'post'} the comment: ${(r.stderr || r.stdout).trim()}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Build the docs/ folder that GitHub Pages serves, so recap links in pull
|
|
2
|
+
// request comments actually resolve.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/publish-pages.mjs [--base https://<user>.github.io/<repo>]
|
|
5
|
+
//
|
|
6
|
+
// Copies every built recap into docs/records/ and writes docs/index.html, an
|
|
7
|
+
// index of the sessions on record. Then, once:
|
|
8
|
+
//
|
|
9
|
+
// Settings → Pages → Source: "Deploy from a branch", branch main, folder /docs
|
|
10
|
+
//
|
|
11
|
+
// After that, `node scripts/post-recap.mjs latest --url-base <base>/recaps`
|
|
12
|
+
// posts a comment whose link works for anyone who can see the repository.
|
|
13
|
+
//
|
|
14
|
+
// Zero cost, no server, no account beyond the GitHub one you already have.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, readdirSync, mkdirSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const storyDir = join(root, 'records');
|
|
22
|
+
const builtDir = join(root, 'ui', 'records');
|
|
23
|
+
const docsDir = join(root, 'docs');
|
|
24
|
+
const outRecaps = join(docsDir, 'records');
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const bIdx = argv.indexOf('--base');
|
|
28
|
+
const BASE = (bIdx !== -1 ? argv[bIdx + 1] : process.env.NEARLY_URL_BASE || '').replace(/\/$/, '');
|
|
29
|
+
|
|
30
|
+
if (!existsSync(storyDir)) { console.error('no records/ yet — run build-recap first'); process.exit(1); }
|
|
31
|
+
|
|
32
|
+
mkdirSync(outRecaps, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
35
|
+
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
|
36
|
+
|
|
37
|
+
// Every session auto-builds its own record when it ends, but a branch record
|
|
38
|
+
// merges them and is what the reviewer gets. Where both exist for a branch, the
|
|
39
|
+
// branch record wins; otherwise the index fills with pages nobody links to.
|
|
40
|
+
const all = readdirSync(storyDir).filter((f) => f.endsWith('.json'));
|
|
41
|
+
const coveredBranches = new Set();
|
|
42
|
+
for (const f of all) {
|
|
43
|
+
try {
|
|
44
|
+
const sb = JSON.parse(readFileSync(join(storyDir, f), 'utf8'));
|
|
45
|
+
if (sb.kind === 'branch' && sb.branch) coveredBranches.add(`${sb.cwd || ''}::${sb.branch}`);
|
|
46
|
+
} catch { /* unreadable storyboard; the loop below reports it */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sessions = [];
|
|
50
|
+
const superseded = [];
|
|
51
|
+
for (const f of all) {
|
|
52
|
+
const slug = f.replace(/\.json$/, '');
|
|
53
|
+
const html = join(builtDir, `${slug}.html`);
|
|
54
|
+
if (!existsSync(html)) { console.warn(`skipping ${slug}: no built page`); continue; }
|
|
55
|
+
|
|
56
|
+
const sb = JSON.parse(readFileSync(join(storyDir, f), 'utf8'));
|
|
57
|
+
if (sb.kind !== 'branch' && coveredBranches.has(`${sb.cwd || ''}::${sb.branch}`)) { superseded.push(slug); continue; }
|
|
58
|
+
const cover = sb.scenes.find((s) => s.kind === 'cover');
|
|
59
|
+
const outcome = sb.scenes.find((s) => s.kind === 'outcome');
|
|
60
|
+
sessions.push({
|
|
61
|
+
slug, name: sb.name, branch: sb.branch, date: sb.date, startedAt: sb.startedAt,
|
|
62
|
+
title: cover?.title ?? slug, total: sb.totalS, supervisor: sb.supervisor,
|
|
63
|
+
refused: (outcome?.notDone ?? []).length, kb: Math.round(statSync(html).size / 1024),
|
|
64
|
+
});
|
|
65
|
+
copyFileSync(html, join(outRecaps, `${slug}.html`));
|
|
66
|
+
}
|
|
67
|
+
sessions.sort((a, b) => b.startedAt - a.startedAt);
|
|
68
|
+
|
|
69
|
+
const rows = sessions.map((s) => `
|
|
70
|
+
<a class="row" href="recaps/${esc(s.slug)}.html">
|
|
71
|
+
<span class="t">${esc(s.title)}</span>
|
|
72
|
+
<span class="m">${esc(s.name)}${s.branch ? ` · ${esc(s.branch)}` : ''} · supervised by ${esc(s.supervisor)}</span>
|
|
73
|
+
<span class="r">${s.refused ? `<b>${s.refused} refused</b>` : 'nothing refused'}</span>
|
|
74
|
+
<span class="d">${esc(s.date)} · ${mmss(s.total)}</span>
|
|
75
|
+
</a>`).join('');
|
|
76
|
+
|
|
77
|
+
const page = `<!doctype html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="utf-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
82
|
+
<title>Session records · Nearly</title>
|
|
83
|
+
<meta name="description" content="What agents did in this repository, including what a human refused.">
|
|
84
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
85
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
|
86
|
+
<style>
|
|
87
|
+
:root {
|
|
88
|
+
--bg:#0B0D11; --panel:#13161C; --panel-2:#191D25; --rule:#242932; --rule-soft:#1B1F27;
|
|
89
|
+
--ink:#E9ECF1; --ink-2:#98A1AF; --ink-3:#667080; --deny:#FF7A7A; --accent:#7C8CFF;
|
|
90
|
+
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
91
|
+
color-scheme: dark;
|
|
92
|
+
}
|
|
93
|
+
*{box-sizing:border-box}
|
|
94
|
+
body{margin:0;background:var(--bg);color:var(--ink);font-family:"Plus Jakarta Sans",ui-sans-serif,system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.55;letter-spacing:-.006em;-webkit-font-smoothing:antialiased}
|
|
95
|
+
.shell{max-width:840px;margin:0 auto;padding-inline:22px;padding-block:52px 80px;display:flex;flex-direction:column;gap:28px}
|
|
96
|
+
.lbl{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);font-weight:500}
|
|
97
|
+
h1{margin:6px 0 0;font-size:27px;font-weight:700;letter-spacing:-.03em}
|
|
98
|
+
.lede{margin:0;color:var(--ink-2);font-size:15.5px;max-width:60ch;line-height:1.6}
|
|
99
|
+
.lede b{color:var(--ink);font-weight:600}
|
|
100
|
+
.list{display:flex;flex-direction:column;gap:10px}
|
|
101
|
+
a.row{display:grid;grid-template-columns:1fr auto;gap:5px 16px;padding:16px 18px;text-decoration:none;color:inherit;background:var(--panel);border:1px solid var(--rule);border-radius:12px;transition:border-color .12s,background .12s}
|
|
102
|
+
a.row:hover{background:var(--panel-2);border-color:var(--ink-3)}
|
|
103
|
+
a.row:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
104
|
+
.t{font-size:16px;font-weight:700;letter-spacing:-.02em;grid-column:1}
|
|
105
|
+
.m{font-family:var(--mono);font-size:11px;color:var(--ink-3);grid-column:1}
|
|
106
|
+
.r{grid-column:2;grid-row:1;text-align:right;font-family:var(--mono);font-size:11px;color:var(--ink-3);white-space:nowrap}
|
|
107
|
+
.r b{color:var(--deny);font-weight:500}
|
|
108
|
+
.d{grid-column:2;grid-row:2;text-align:right;font-family:var(--mono);font-size:11px;color:var(--ink-3);white-space:nowrap}
|
|
109
|
+
.empty{padding:26px 18px;color:var(--ink-3);border:1px dashed var(--rule);border-radius:12px}
|
|
110
|
+
.foot{color:var(--ink-3);font-size:12.5px;max-width:74ch;border-top:1px solid var(--rule-soft);padding-top:18px}
|
|
111
|
+
@media (max-width:560px){a.row{grid-template-columns:1fr}.r,.d{grid-column:1;text-align:left}}
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div class="shell">
|
|
116
|
+
<div>
|
|
117
|
+
<span class="lbl">Nearly</span>
|
|
118
|
+
<h1>Session records</h1>
|
|
119
|
+
</div>
|
|
120
|
+
<p class="lede">Each entry is the record of one coding-agent session: the task it was given, every action a human held or refused, the changes it made, and anything that was rolled back. <b>A diff tells you what changed. These tell you what nearly happened.</b></p>
|
|
121
|
+
<div class="list">${rows || '<div class="empty">No sessions recorded yet.</div>'}</div>
|
|
122
|
+
<p class="foot">Generated ${new Date().toLocaleString('en-GB')} by the Nearly. Every figure on these pages is computed from the session recordings, not written by a model.</p>
|
|
123
|
+
</div>
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
writeFileSync(join(docsDir, 'index.html'), page);
|
|
129
|
+
writeFileSync(join(docsDir, '.nojekyll'), '');
|
|
130
|
+
|
|
131
|
+
console.log(`docs/ built — ${sessions.length} record(s)`);
|
|
132
|
+
if (superseded.length) console.log(` (${superseded.length} per-session record(s) superseded by a branch record)`);
|
|
133
|
+
for (const s of sessions) console.log(` ${s.slug.padEnd(22)} ${s.refused} refused ${s.kb} KB`);
|
|
134
|
+
console.log('');
|
|
135
|
+
if (BASE) {
|
|
136
|
+
console.log(`Index will be at ${BASE}/`);
|
|
137
|
+
console.log(`Post a recap with node scripts/post-recap.mjs latest --url-base ${BASE}/recaps`);
|
|
138
|
+
} else {
|
|
139
|
+
console.log('Next: commit docs/, then Settings → Pages → branch main, folder /docs.');
|
|
140
|
+
console.log('Then re-run with --base https://<user>.github.io/<repo> for the exact commands.');
|
|
141
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// What the pre-push hook runs. Builds the branch's session record, shows what
|
|
2
|
+
// it found, and asks whether to hand it to the reviewer.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/push-record.mjs <repo-path>
|
|
5
|
+
//
|
|
6
|
+
// Exits 0 no matter what. A recap is never worth failing someone's push over.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
|
|
14
|
+
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
15
|
+
const repo = resolve(process.argv[2] || '.');
|
|
16
|
+
function configured() {
|
|
17
|
+
try {
|
|
18
|
+
const f = join(root, '.nearly.json');
|
|
19
|
+
if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || '';
|
|
20
|
+
} catch { /* fall through to the env var */ }
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/$/, '');
|
|
24
|
+
// Narration takes about a second a scene, which is too long to make someone
|
|
25
|
+
// wait at a push. Opt in with NEARLY_AUDIO=1 when you are making the good one.
|
|
26
|
+
const WANT_AUDIO = process.env.NEARLY_AUDIO === '1';
|
|
27
|
+
|
|
28
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
29
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
30
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
31
|
+
|
|
32
|
+
function bail(msg) { if (msg) console.error(dim(`nearly: ${msg}`)); process.exit(0); }
|
|
33
|
+
|
|
34
|
+
let branch;
|
|
35
|
+
try {
|
|
36
|
+
branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
37
|
+
} catch { bail('could not read the branch'); }
|
|
38
|
+
if (!branch || branch === 'HEAD') bail('detached HEAD, nothing to record');
|
|
39
|
+
|
|
40
|
+
const args = ['--branch', branch, '--repo', repo];
|
|
41
|
+
if (!WANT_AUDIO) args.push('--no-audio');
|
|
42
|
+
const build = spawnSync(process.execPath, [join(root, 'scripts', 'build-recap.mjs'), ...args],
|
|
43
|
+
{ cwd: root, encoding: 'utf8', timeout: 180_000 });
|
|
44
|
+
|
|
45
|
+
if (build.status !== 0) {
|
|
46
|
+
// A branch with no agent sessions is the normal case for hand-written work,
|
|
47
|
+
// not an error worth shouting about. Node prints a stack plus its own version
|
|
48
|
+
// footer, so pick the message line rather than the last line.
|
|
49
|
+
const err = build.stderr || '';
|
|
50
|
+
if (/no recordings on branch/.test(err)) bail(`no agent sessions recorded on ${branch}`);
|
|
51
|
+
const line = err.split('\n').map((l) => l.trim())
|
|
52
|
+
.find((l) => /^(Error|TypeError|ReferenceError|SyntaxError)[:\s]/.test(l));
|
|
53
|
+
bail(line || `could not build the record (exit ${build.status})`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
57
|
+
const slug = `${safe(basename(repo))}--${safe(branch)}`;
|
|
58
|
+
const storyPath = join(root, 'records', `${slug}.json`);
|
|
59
|
+
if (!existsSync(storyPath)) bail('record built but not found on disk');
|
|
60
|
+
|
|
61
|
+
const sb = JSON.parse(readFileSync(storyPath, 'utf8'));
|
|
62
|
+
const cover = sb.scenes.find((s) => s.kind === 'cover');
|
|
63
|
+
const outcome = sb.scenes.find((s) => s.kind === 'outcome');
|
|
64
|
+
const notDone = outcome?.notDone ?? [];
|
|
65
|
+
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
|
66
|
+
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(bold(` Session record for ${branch}`));
|
|
69
|
+
console.log(dim(` ${sb.runs} session${sb.runs === 1 ? '' : 's'} · ${cover.title} · ${mmss(sb.totalS)} to watch`));
|
|
70
|
+
if (notDone.length) {
|
|
71
|
+
console.log('');
|
|
72
|
+
console.log(` ${red('These never happened, and the diff will not show them:')}`);
|
|
73
|
+
for (const n of notDone) {
|
|
74
|
+
const by = n.by === 'policy' ? 'blocked by policy' : 'you refused it';
|
|
75
|
+
console.log(` · ${n.tool} ${n.what} ${dim(`(${by})`)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(dim(` ${URL_BASE ? `${URL_BASE}/${slug}.html` : `ui/records/${slug}.html (set NEARLY_URL_BASE to publish it)`}`));
|
|
80
|
+
console.log('');
|
|
81
|
+
|
|
82
|
+
// A push is the right moment to update: it happens often for anyone actually
|
|
83
|
+
// using this, the person is present and waiting, and it is nowhere near the path
|
|
84
|
+
// an agent's tool call travels. Turning it on for a repo happens once, and the
|
|
85
|
+
// hooks are excluded on purpose, so without this an active user would never
|
|
86
|
+
// hear about a fix.
|
|
87
|
+
try {
|
|
88
|
+
const { checkForUpdate, applyUpdate } = await import('./update-check.mjs');
|
|
89
|
+
applyUpdate(await checkForUpdate());
|
|
90
|
+
} catch { /* never worth failing a push over */ }
|
|
91
|
+
|
|
92
|
+
// gh is only useful if there is a pull request to comment on
|
|
93
|
+
const hasGh = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
94
|
+
const pr = hasGh ? spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' }) : null;
|
|
95
|
+
if (!pr || pr.status !== 0) {
|
|
96
|
+
console.log(dim(' No open pull request for this branch yet. Raise one, then push again to attach the record.'));
|
|
97
|
+
console.log('');
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
const prUrl = (() => { try { return JSON.parse(pr.stdout).url; } catch { return null; } })();
|
|
101
|
+
|
|
102
|
+
if (process.env.NEARLY_NO_TTY === '1' || !process.stdin.isTTY) {
|
|
103
|
+
console.log(dim(' No terminal to ask on, so nothing was posted.'));
|
|
104
|
+
console.log(dim(` Post it yourself: node scripts/post-recap.mjs ${slug}${URL_BASE ? ` --url-base ${URL_BASE}` : ''}`));
|
|
105
|
+
console.log('');
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
const answer = await new Promise((r) => rl.question(` Post this record to ${prUrl || 'the pull request'}? [y/N] `, r))
|
|
111
|
+
.finally(() => rl.close());
|
|
112
|
+
|
|
113
|
+
if (!/^y(es)?$/i.test(String(answer).trim())) {
|
|
114
|
+
console.log(dim(' Not posted. Push continues.'));
|
|
115
|
+
console.log('');
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const postArgs = [join(root, 'scripts', 'post-recap.mjs'), slug];
|
|
120
|
+
if (URL_BASE) postArgs.push('--url-base', URL_BASE);
|
|
121
|
+
const post = spawnSync(process.execPath, postArgs, { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
|
122
|
+
console.log(post.status === 0
|
|
123
|
+
? ` Posted. ${(post.stdout || '').trim()}`
|
|
124
|
+
: red(` Could not post: ${(post.stderr || post.stdout || '').trim().split('\n').pop()}`));
|
|
125
|
+
console.log('');
|
|
126
|
+
process.exit(0);
|