great-cto 3.75.10 → 3.76.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/board/.claude-plugin/plugin.json +1 -1
- package/board/agents/architect.md +4 -0
- package/board/agents/ci-resolver.md +1 -0
- package/board/agents/design-advisor.md +28 -0
- package/board/agents/devops.md +1 -0
- package/board/agents/product-owner.md +13 -1
- package/board/agents/senior-dev.md +25 -9
- package/board/packages/board/lib/alerts.mjs +46 -1
- package/board/packages/board/lib/gate-run.sh +30 -0
- package/board/packages/board/lib/instant-alerts.mjs +159 -0
- package/board/packages/board/lib/merge-readiness.mjs +315 -0
- package/board/packages/board/lib/routes.mjs +29 -3
- package/board/packages/board/openapi.json +2996 -0
- package/board/packages/board/public/assets/icon-192.png +0 -0
- package/board/packages/board/public/assets/icon-512.png +0 -0
- package/board/packages/board/public/assets/icon-maskable-512.png +0 -0
- package/board/packages/board/public/assets/work-view.js +83 -3
- package/board/packages/board/public/index.html +112 -1
- package/board/packages/board/public/manifest.webmanifest +16 -0
- package/board/packages/board/public/sw.js +1 -1
- package/board/packages/board/route-coverage-baseline.json +25 -0
- package/board/packages/board/server.mjs +8 -1
- package/board/scripts/lib/gate-run-record.mjs +75 -0
- package/board/scripts/lib/handoff-state.mjs +478 -0
- package/board/scripts/lib/workspace.mjs +277 -0
- package/board/scripts/lib/worktree-state.mjs +128 -0
- package/board/shared/pipeline.toml +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* handoff-state — what /save records about WHERE the work stood, and what
|
|
4
|
+
* /resume checks before it believes the note.
|
|
5
|
+
*
|
|
6
|
+
* Two failures this exists for:
|
|
7
|
+
*
|
|
8
|
+
* 1. Several sessions share one working tree. The next session inherits a
|
|
9
|
+
* branch, a dirty tree, a stash and a dev server it did not start, and the
|
|
10
|
+
* session log said none of it. So /save now captures the run state
|
|
11
|
+
* (`captureState`) and writes it into the log as a block with a
|
|
12
|
+
* machine-readable marker.
|
|
13
|
+
*
|
|
14
|
+
* 2. A note is a snapshot; the tree keeps moving. Other sessions commit on
|
|
15
|
+
* top of it, and /resume used to summarise the note as if it were current.
|
|
16
|
+
* `staleness` answers "how far has HEAD moved since the saved sha" — by
|
|
17
|
+
* ancestry when git still knows the sha, by the saved time when it does not.
|
|
18
|
+
*
|
|
19
|
+
* `parseLog` reads a /save log back: the marker, the goal, the first step, and
|
|
20
|
+
* every Done item with its `Verify:` line — so /resume can re-run the cheap
|
|
21
|
+
* proofs instead of repeating "done" on the note's word. `classifyVerify`
|
|
22
|
+
* decides which proofs are safe to re-run unattended: a command that could
|
|
23
|
+
* change anything (push, deploy, rm, a redirect into a file) is never re-run
|
|
24
|
+
* by /resume, however cheap it looks.
|
|
25
|
+
*
|
|
26
|
+
* Read-only by construction: every git call here is a query. The stash is
|
|
27
|
+
* COUNTED with `git stash list`, never applied, popped or dropped — a stash in
|
|
28
|
+
* a shared tree may be another session's work.
|
|
29
|
+
*
|
|
30
|
+
* Zero dependencies; every probe is best-effort and a failure reads as "not
|
|
31
|
+
* known", never as a crash. The lsof probe is optional and never fails capture.
|
|
32
|
+
*
|
|
33
|
+
* The run-state / verify / start-here fields adapt an idea from the MIT-licensed
|
|
34
|
+
* handoff skill in techwolf-ai/ai-first-toolkit (see NOTICE.md); the code and
|
|
35
|
+
* wording here are our own.
|
|
36
|
+
*
|
|
37
|
+
* CLI:
|
|
38
|
+
* node handoff-state.mjs capture [--json] [--no-servers]
|
|
39
|
+
* node handoff-state.mjs check (--log FILE | --saved-at ISO --sha SHA [--branch B]) [--json]
|
|
40
|
+
*/
|
|
41
|
+
import { spawnSync } from 'node:child_process';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import os from 'node:os';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { isMainModule } from './is-main.mjs';
|
|
46
|
+
|
|
47
|
+
export const DIRTY_CAP = 30;
|
|
48
|
+
export const COMMITS_CAP = 10;
|
|
49
|
+
|
|
50
|
+
/** Ports given to per-task workspaces (ADR-047 section 3). 3141 stays the main checkout's board. */
|
|
51
|
+
export const WORKSPACE_PORT_FIRST = 3142;
|
|
52
|
+
export const WORKSPACE_PORT_LAST = 3241;
|
|
53
|
+
|
|
54
|
+
/** Ports a dev server usually sits on. Anything else listening is not ours to report. */
|
|
55
|
+
export const DEV_PORTS = new Set([
|
|
56
|
+
3000, 3001, 3002, 3003, 3004, 3005, 3141, 3333, 4000, 4173, 4200, 4321,
|
|
57
|
+
5000, 5001, 5173, 5174, 5555, 6006, 8000, 8001, 8080, 8081, 8787, 8888, 9000, 19006,
|
|
58
|
+
// Without these, /save reports "no servers" for a workspace that has one.
|
|
59
|
+
...Array.from({ length: WORKSPACE_PORT_LAST - WORKSPACE_PORT_FIRST + 1 }, (_, i) => WORKSPACE_PORT_FIRST + i),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** OS daemons that squat on dev ports (macOS AirPlay holds :5000 and :7000). Not a server you left running. */
|
|
63
|
+
const OS_DAEMONS = /^(ControlCe|ControlCenter|rapportd|AirPlayXPC|launc|mDNSRespo|sharingd)/;
|
|
64
|
+
|
|
65
|
+
function runGit(args, { cwd, env }) {
|
|
66
|
+
try {
|
|
67
|
+
const r = spawnSync('git', args, { cwd, env: env || process.env, encoding: 'utf8', timeout: 10_000 });
|
|
68
|
+
return { ok: r.status === 0, out: (r.stdout || '').replace(/\s+$/, ''), raw: r.stdout || '' };
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, out: '', raw: '' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tilde(p) {
|
|
75
|
+
if (!p) return p;
|
|
76
|
+
const home = os.homedir();
|
|
77
|
+
if (home && (p === home || p.startsWith(home + path.sep))) return '~' + p.slice(home.length);
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `git status --porcelain=v1 -z` → [{status, path, from?}]. -z keeps odd names unquoted. */
|
|
82
|
+
export function parseStatusZ(raw) {
|
|
83
|
+
const parts = raw.split('\0');
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let i = 0; i < parts.length; i++) {
|
|
86
|
+
const e = parts[i];
|
|
87
|
+
if (!e || e.length < 4) continue;
|
|
88
|
+
const xy = e.slice(0, 2);
|
|
89
|
+
const entry = { status: xy.trim() || xy, path: e.slice(3) };
|
|
90
|
+
if (/[RC]/.test(xy)) { entry.from = parts[i + 1]; i++; }
|
|
91
|
+
out.push(entry);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `lsof -nP -iTCP -sTCP:LISTEN` output → dev servers, one per pid+port. */
|
|
97
|
+
export function parseLsof(text) {
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const line of String(text || '').split('\n')) {
|
|
101
|
+
const m = line.match(/^(\S+)\s+(\d+)\s.*\bTCP\s+\S*:(\d+)\s+\(LISTEN\)/);
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
const port = Number(m[3]);
|
|
104
|
+
const pid = Number(m[2]);
|
|
105
|
+
if (!DEV_PORTS.has(port) || OS_DAEMONS.test(m[1])) continue;
|
|
106
|
+
const key = `${pid}:${port}`;
|
|
107
|
+
if (seen.has(key)) continue;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
out.push({ command: m[1], pid, port });
|
|
110
|
+
}
|
|
111
|
+
return out.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultLsof() {
|
|
115
|
+
if (typeof process.getuid !== 'function') return '';
|
|
116
|
+
const r = spawnSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-u', String(process.getuid())],
|
|
117
|
+
{ encoding: 'utf8', timeout: 3_000 });
|
|
118
|
+
return r.stdout || '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this a linked worktree? `git` runs one git command and answers { ok, out }.
|
|
123
|
+
* False when git cannot say: callers treat that as "not known to be linked".
|
|
124
|
+
*/
|
|
125
|
+
export function isLinkedWorktree(git) {
|
|
126
|
+
const gd = git('rev-parse', '--path-format=absolute', '--git-dir');
|
|
127
|
+
const cd = git('rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
128
|
+
return gd.ok && cd.ok && path.resolve(gd.out) !== path.resolve(cd.out);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The run state of the tree at `cwd`, right now.
|
|
133
|
+
* Options: `probeServers` (default true), `lsof` (a function returning lsof
|
|
134
|
+
* text — injectable for tests), `env` (for git), `now` (a Date).
|
|
135
|
+
*/
|
|
136
|
+
export function captureState({ cwd = process.cwd(), probeServers = true, lsof, env, now } = {}) {
|
|
137
|
+
const capturedAt = (now instanceof Date ? now : new Date()).toISOString();
|
|
138
|
+
const g = (...args) => runGit(args, { cwd, env });
|
|
139
|
+
|
|
140
|
+
let servers = [];
|
|
141
|
+
let serversProbed = false;
|
|
142
|
+
if (lsof || probeServers) {
|
|
143
|
+
try { servers = parseLsof((lsof || defaultLsof)()); serversProbed = true; } catch { servers = []; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const base = {
|
|
147
|
+
inRepo: false, cwd, branch: null, detached: false, sha: null, shortSha: null,
|
|
148
|
+
dirty: [], dirtyTotal: 0, upstream: null, ahead: null, behind: null,
|
|
149
|
+
worktree: null, linkedWorktree: false, stashCount: 0, servers, serversProbed, capturedAt,
|
|
150
|
+
};
|
|
151
|
+
if (g('rev-parse', '--is-inside-work-tree').out !== 'true') return base;
|
|
152
|
+
|
|
153
|
+
const s = { ...base, inRepo: true };
|
|
154
|
+
const head = g('rev-parse', 'HEAD');
|
|
155
|
+
if (head.ok && /^[0-9a-f]{7,}$/.test(head.out)) {
|
|
156
|
+
s.sha = head.out;
|
|
157
|
+
s.shortSha = g('rev-parse', '--short', 'HEAD').out || head.out.slice(0, 7);
|
|
158
|
+
}
|
|
159
|
+
const br = g('branch', '--show-current');
|
|
160
|
+
s.branch = br.out || null;
|
|
161
|
+
s.detached = !s.branch && !!s.sha;
|
|
162
|
+
|
|
163
|
+
const st = g('status', '--porcelain=v1', '-z');
|
|
164
|
+
if (st.ok) {
|
|
165
|
+
const all = parseStatusZ(st.raw);
|
|
166
|
+
s.dirtyTotal = all.length;
|
|
167
|
+
s.dirty = all.slice(0, DIRTY_CAP);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const up = g('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}');
|
|
171
|
+
if (up.ok && up.out) {
|
|
172
|
+
s.upstream = up.out;
|
|
173
|
+
const lr = g('rev-list', '--left-right', '--count', '@{upstream}...HEAD');
|
|
174
|
+
const m = lr.out.match(/^(\d+)\s+(\d+)$/);
|
|
175
|
+
if (m) { s.behind = Number(m[1]); s.ahead = Number(m[2]); }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const top = g('rev-parse', '--show-toplevel');
|
|
179
|
+
s.worktree = top.ok ? top.out : null;
|
|
180
|
+
s.linkedWorktree = isLinkedWorktree(g);
|
|
181
|
+
|
|
182
|
+
// Counted, never touched: a stash in a shared tree may be someone else's work.
|
|
183
|
+
const stash = g('stash', 'list');
|
|
184
|
+
s.stashCount = stash.ok && stash.out ? stash.out.split('\n').length : 0;
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The `## Run state` block /save writes into the session log. */
|
|
189
|
+
export function renderRunState(s) {
|
|
190
|
+
const L = ['## Run state', ''];
|
|
191
|
+
L.push(`<!-- handoff-state saved_at=${s.capturedAt} sha=${s.sha || ''} branch=${s.branch || ''} -->`);
|
|
192
|
+
if (!s.inRepo) {
|
|
193
|
+
L.push(`- not a git repository (${tilde(s.cwd)}) — no branch or sha to check against`);
|
|
194
|
+
} else {
|
|
195
|
+
const where = s.branch ? `\`${s.branch}\`` : '(detached HEAD)';
|
|
196
|
+
const at = s.shortSha ? ` @ \`${s.shortSha}\`` : ' (no commits yet)';
|
|
197
|
+
const wt = s.worktree ? ` · worktree \`${tilde(s.worktree)}\`${s.linkedWorktree ? ' (linked)' : ''}` : '';
|
|
198
|
+
L.push(`- branch: ${where}${at}${wt}`);
|
|
199
|
+
L.push(s.upstream
|
|
200
|
+
? `- upstream: \`${s.upstream}\` — ahead ${s.ahead ?? '?'}, behind ${s.behind ?? '?'}`
|
|
201
|
+
: '- upstream: none');
|
|
202
|
+
if (!s.dirtyTotal) L.push('- tree: clean');
|
|
203
|
+
else {
|
|
204
|
+
L.push(`- tree: dirty — ${s.dirtyTotal} file${s.dirtyTotal === 1 ? '' : 's'}`);
|
|
205
|
+
for (const f of s.dirty) L.push(` - \`${f.status} ${f.path}\`${f.from ? ` (from ${f.from})` : ''}`);
|
|
206
|
+
if (s.dirtyTotal > s.dirty.length) L.push(` - … and ${s.dirtyTotal - s.dirty.length} more`);
|
|
207
|
+
}
|
|
208
|
+
if (s.stashCount) {
|
|
209
|
+
L.push(`- stash: ${s.stashCount} entr${s.stashCount === 1 ? 'y' : 'ies'} — may belong to another session; do not pop or drop without asking`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!s.serversProbed) L.push('- servers: not probed');
|
|
213
|
+
else if (!s.servers.length) L.push('- servers: none on common dev ports');
|
|
214
|
+
else L.push(`- servers: ${s.servers.map((v) => `\`:${v.port}\` ${v.command} (pid ${v.pid})`).join(', ')}`);
|
|
215
|
+
L.push(`- captured: ${s.capturedAt}`);
|
|
216
|
+
return L.join('\n') + '\n';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** git accepts ISO 8601 for --since; milliseconds only confuse it. */
|
|
220
|
+
function sinceArg(iso) {
|
|
221
|
+
const t = Date.parse(iso);
|
|
222
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* How far the tree has moved since a note saved at `savedAt` on `sha`
|
|
227
|
+
* (and, when recorded, `branch`).
|
|
228
|
+
*/
|
|
229
|
+
export function staleness({ cwd = process.cwd(), savedAt, sha, branch, env, cap = COMMITS_CAP } = {}) {
|
|
230
|
+
const g = (...args) => runGit(args, { cwd, env });
|
|
231
|
+
const r = {
|
|
232
|
+
inRepo: false, savedAt: savedAt || null, savedSha: sha || null, savedBranch: branch || null,
|
|
233
|
+
currentBranch: null, currentSha: null, shaKnown: false, shaOnHead: null,
|
|
234
|
+
branchChanged: false, commitsSince: 0, commits: [], basis: 'none', stale: false,
|
|
235
|
+
};
|
|
236
|
+
if (g('rev-parse', '--is-inside-work-tree').out !== 'true') return r;
|
|
237
|
+
r.inRepo = true;
|
|
238
|
+
r.currentBranch = g('branch', '--show-current').out || null;
|
|
239
|
+
const head = g('rev-parse', 'HEAD');
|
|
240
|
+
r.currentSha = head.ok ? head.out : null;
|
|
241
|
+
if (!r.currentSha) return r;
|
|
242
|
+
|
|
243
|
+
let range = null;
|
|
244
|
+
if (sha && /^[0-9a-f]{4,40}$/i.test(sha) && g('cat-file', '-e', `${sha}^{commit}`).ok) {
|
|
245
|
+
r.shaKnown = true;
|
|
246
|
+
r.shaOnHead = g('merge-base', '--is-ancestor', sha, 'HEAD').ok;
|
|
247
|
+
range = [`${sha}..HEAD`];
|
|
248
|
+
r.basis = 'sha';
|
|
249
|
+
} else if (sinceArg(savedAt)) {
|
|
250
|
+
range = [`--since=${sinceArg(savedAt)}`, 'HEAD'];
|
|
251
|
+
r.basis = 'time';
|
|
252
|
+
}
|
|
253
|
+
if (range) {
|
|
254
|
+
const n = g('rev-list', '--count', ...range);
|
|
255
|
+
r.commitsSince = n.ok ? Number(n.out) || 0 : 0;
|
|
256
|
+
const log = g('log', `-n${cap}`, '--format=%h%x09%s', ...range);
|
|
257
|
+
r.commits = log.ok && log.out
|
|
258
|
+
? log.out.split('\n').map((l) => { const [h, ...rest] = l.split('\t'); return { sha: h, subject: rest.join('\t') }; })
|
|
259
|
+
: [];
|
|
260
|
+
}
|
|
261
|
+
r.branchChanged = Boolean((branch && r.currentBranch !== branch) || (r.shaKnown && r.shaOnHead === false));
|
|
262
|
+
r.stale = r.commitsSince > 0 || r.branchChanged;
|
|
263
|
+
return r;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── reading a /save log back ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
const WRITES = /(^|\s)(rm|rmdir|mv|cp|dd|chmod|chown|sudo|kill|pkill|killall|truncate|tee|touch|mkdir|ln|scp|rsync|ssh|kubectl|terraform|helm|gcloud|aws|az|wrangler|vercel|fly|heroku)(\s|$)|\bgit\s+(push|commit|stash|checkout|switch|reset|restore|clean|rebase|merge|pull|cherry-pick|revert|tag|am|apply|worktree\s+(add|remove|prune)|branch\s+-[dDmM]|gc|prune|update-ref|fetch)\b|\b(publish|deploy|release)\b|\bnpm\s+(install|i|ci|uninstall|update|version|publish)\b|\bdocker\s+(push|rm|rmi|run|compose)\b|\bcurl\b[^|;&]*(\s-X\s*(POST|PUT|PATCH|DELETE)|\s(-d|--data|-F|--form|-T|--upload-file)\b)|\bbd\s+(create|close|update|delete|dep)\b|\bsed\s+-i\b/i;
|
|
269
|
+
const SLOW = /ci-local|playwright|\be2e\b|\bnpm\s+run\s+build\b|\b(vite|next|tsc|cargo|go|make|gradle|mvn|xcodebuild)\s+build\b|^\s*(npm|pnpm|yarn)\s+(run\s+)?test\s*$|^\s*(pytest|cargo\s+test|go\s+test\s+\.\/\.\.\.)\s*$|\bmake\b|\bdocker\s+build\b|\bmaestro\b|\beval\b/i;
|
|
270
|
+
const READ_ONLY = [
|
|
271
|
+
/^node\s+--test\b/,
|
|
272
|
+
/^(npm|pnpm|yarn)\s+(run\s+)?test\s+--\s+\S/,
|
|
273
|
+
/^npx\s+vitest\s+run\s+\S/,
|
|
274
|
+
/^(pytest|python3?\s+-m\s+pytest)\s+\S/,
|
|
275
|
+
/^go\s+test\s+\S/,
|
|
276
|
+
/^git\s+(log|status|diff|show|rev-parse|merge-base|ls-files|branch(\s+--show-current|\s+--contains\b.*|\s+-a|\s+-r|\s+--list.*)?|describe|cat-file|blame|grep)(\s|$)/,
|
|
277
|
+
/^(grep|rg|ls|cat|head|tail|wc|test|\[|diff|cmp|stat|file|jq|find(?!.*\s-(delete|exec)))(\s|$)/,
|
|
278
|
+
/^gh\s+(run|pr|issue)\s+(list|view|checks)\b/,
|
|
279
|
+
/^node\s+\S*(handoff-state|pipeline-state|gate-check|doctor)\.mjs\s+(check|capture)?/,
|
|
280
|
+
/^(true|echo|printf)(\s|$)/,
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 'cheap' — read-only and quick: /resume may re-run it unattended.
|
|
285
|
+
* 'slow' — read-only but heavy (full suites, builds, e2e): listed, not re-run.
|
|
286
|
+
* 'unsafe' — could change files, history, or anything outside the machine: never re-run.
|
|
287
|
+
* 'unlisted' — not recognised; treated as not safe to run unattended.
|
|
288
|
+
*/
|
|
289
|
+
export function classifyVerify(cmd) {
|
|
290
|
+
const c = String(cmd || '').trim();
|
|
291
|
+
if (!c) return 'unlisted';
|
|
292
|
+
if (/`|\$\(/.test(c)) return 'unlisted';
|
|
293
|
+
// A redirect into anything but /dev/null writes a file.
|
|
294
|
+
if (/(?<![0-9&])>>?\s*(?!&|\/dev\/null)/.test(c)) return 'unsafe';
|
|
295
|
+
const segments = c.split(/\s*(?:\|\||&&|;|\|)\s*/).filter(Boolean);
|
|
296
|
+
let worst = 'cheap';
|
|
297
|
+
const rank = { cheap: 0, unlisted: 1, slow: 2, unsafe: 3 };
|
|
298
|
+
for (const seg of segments) {
|
|
299
|
+
let k;
|
|
300
|
+
if (WRITES.test(seg)) k = 'unsafe';
|
|
301
|
+
else if (SLOW.test(seg)) k = 'slow';
|
|
302
|
+
else if (READ_ONLY.some((re) => re.test(seg))) k = 'cheap';
|
|
303
|
+
else k = 'unlisted';
|
|
304
|
+
if (rank[k] > rank[worst]) worst = k;
|
|
305
|
+
}
|
|
306
|
+
return worst;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseVerify(text) {
|
|
310
|
+
const t = text.trim();
|
|
311
|
+
const nv = t.match(/^not\s+verified\b\s*[—–:-]*\s*(.*)$/i);
|
|
312
|
+
if (nv) return { command: null, expect: null, kind: 'none', reason: nv[1].trim() || 'no reason given' };
|
|
313
|
+
let command; let rest;
|
|
314
|
+
const bt = t.match(/^`([^`]+)`\s*(.*)$/);
|
|
315
|
+
if (bt) { command = bt[1].trim(); rest = bt[2]; }
|
|
316
|
+
else {
|
|
317
|
+
const split = t.split(/\s+(?:→|->)\s+/);
|
|
318
|
+
command = split[0].trim(); rest = split.length > 1 ? `→ ${split.slice(1).join(' → ')}` : '';
|
|
319
|
+
}
|
|
320
|
+
const ex = rest.match(/^(?:→|->)\s*(.+)$/);
|
|
321
|
+
return { command, expect: ex ? ex[1].trim() : null, kind: classifyVerify(command) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function section(lines, name) {
|
|
325
|
+
const start = lines.findIndex((l) => new RegExp(`^##\\s+${name}\\s*$`, 'i').test(l));
|
|
326
|
+
if (start < 0) return [];
|
|
327
|
+
const out = [];
|
|
328
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
329
|
+
if (/^#{1,2}\s/.test(lines[i])) break;
|
|
330
|
+
out.push(lines[i]);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A /save log → { savedAt, sha, branch, goal, firstStep, done: [{item, verify}] }. */
|
|
336
|
+
export function parseLog(text) {
|
|
337
|
+
const src = String(text || '');
|
|
338
|
+
const lines = src.split(/\r?\n/);
|
|
339
|
+
const r = { savedAt: null, sha: null, branch: null, goal: null, firstStep: null, done: [] };
|
|
340
|
+
|
|
341
|
+
const marker = src.match(/<!--\s*handoff-state\s+([^>]*?)\s*-->/);
|
|
342
|
+
if (marker) {
|
|
343
|
+
for (const kv of marker[1].split(/\s+/)) {
|
|
344
|
+
const [k, ...v] = kv.split('=');
|
|
345
|
+
const val = v.join('=').trim();
|
|
346
|
+
if (!val || val === 'none') continue;
|
|
347
|
+
if (k === 'saved_at') r.savedAt = val;
|
|
348
|
+
else if (k === 'sha') r.sha = val;
|
|
349
|
+
else if (k === 'branch') r.branch = val;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!r.savedAt) {
|
|
353
|
+
// An older note: no marker, but its frontmatter still dates it (local time).
|
|
354
|
+
const d = src.match(/^date:\s*(\d{4})-(\d{2})-(\d{2})\s*$/m);
|
|
355
|
+
const t = src.match(/^time:\s*(\d{1,2}):(\d{2})\s*$/m);
|
|
356
|
+
if (d) {
|
|
357
|
+
const dt = new Date(Number(d[1]), Number(d[2]) - 1, Number(d[3]), t ? Number(t[1]) : 0, t ? Number(t[2]) : 0);
|
|
358
|
+
if (!Number.isNaN(dt.getTime())) r.savedAt = dt.toISOString();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const goal = section(lines, 'Goal').map((l) => l.trim()).filter(Boolean);
|
|
363
|
+
r.goal = goal.length ? goal[0].replace(/^[-*]\s+/, '') : null;
|
|
364
|
+
const start = section(lines, 'Start here').map((l) => l.trim()).find((l) => /^(\d+[.)]|[-*])\s+/.test(l));
|
|
365
|
+
r.firstStep = start ? start.replace(/^(\d+[.)]|[-*])\s+/, '') : null;
|
|
366
|
+
|
|
367
|
+
let cur = null;
|
|
368
|
+
for (const l of section(lines, 'Done')) {
|
|
369
|
+
const item = l.match(/^[-*]\s+(.*)$/);
|
|
370
|
+
if (item) {
|
|
371
|
+
cur = { item: item[1].trim(), verify: null };
|
|
372
|
+
if (!/^\*?\(none\)\*?$/i.test(cur.item)) r.done.push(cur);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const v = l.match(/^\s+(?:[-*]\s+)?Verify:\s*(.*)$/i);
|
|
376
|
+
if (v && cur) cur.verify = parseVerify(v[1]);
|
|
377
|
+
}
|
|
378
|
+
return r;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const WHY_NOT = {
|
|
384
|
+
slow: 'not re-run: heavy — run it yourself if the item matters now',
|
|
385
|
+
unsafe: 'not re-run: could change files, history or something outside this machine',
|
|
386
|
+
unlisted: 'not re-run: not a recognised read-only command',
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
export function renderCheck(st, log) {
|
|
390
|
+
const L = [];
|
|
391
|
+
const short = (s) => (s ? s.slice(0, 7) : '?');
|
|
392
|
+
const savedDesc = `saved ${st.savedAt || '?'}${st.savedSha ? ` at ${short(st.savedSha)}` : ''}${st.savedBranch ? ` on ${st.savedBranch}` : ''}`;
|
|
393
|
+
if (!st.inRepo) L.push('Staleness unknown: not a git repository — read the note as a snapshot of unknown age.');
|
|
394
|
+
else if (st.commitsSince > 0) {
|
|
395
|
+
L.push(`STALE: ${st.commitsSince} commit${st.commitsSince === 1 ? '' : 's'} since this note (${savedDesc}) — the note may be stale.`);
|
|
396
|
+
} else if (st.branchChanged) {
|
|
397
|
+
L.push(`STALE: the branch changed since this note (${savedDesc}; now ${st.currentBranch || 'detached HEAD'}) — the note may be stale.`);
|
|
398
|
+
} else {
|
|
399
|
+
L.push(`Note is current: no commits since it was written (${savedDesc}).`);
|
|
400
|
+
}
|
|
401
|
+
if (st.inRepo && st.branchChanged && st.commitsSince > 0) {
|
|
402
|
+
L.push(`Branch changed: note was on ${st.savedBranch || '(unrecorded)'}, now on ${st.currentBranch || 'detached HEAD'}${st.shaOnHead === false ? '; the saved sha is not under HEAD' : ''}.`);
|
|
403
|
+
}
|
|
404
|
+
if (st.basis === 'time') L.push('(saved sha unknown to git — counted by commit time instead)');
|
|
405
|
+
for (const c of st.commits) L.push(` ${c.sha} ${c.subject}`);
|
|
406
|
+
if (st.commitsSince > st.commits.length) L.push(` … and ${st.commitsSince - st.commits.length} more`);
|
|
407
|
+
|
|
408
|
+
if (log) {
|
|
409
|
+
L.push('');
|
|
410
|
+
if (log.goal) L.push(`Goal: ${log.goal}`);
|
|
411
|
+
L.push(`Start here: ${log.firstStep || '(the note records no first step)'}`);
|
|
412
|
+
if (log.done.length) {
|
|
413
|
+
L.push('');
|
|
414
|
+
L.push('Done items and their proof:');
|
|
415
|
+
for (const d of log.done) {
|
|
416
|
+
if (!d.verify) { L.push(` [no proof] ${d.item} — the note gives no Verify line`); continue; }
|
|
417
|
+
if (d.verify.kind === 'none') { L.push(` [unverified] ${d.item} — ${d.verify.reason}`); continue; }
|
|
418
|
+
const exp = d.verify.expect ? ` → ${d.verify.expect}` : '';
|
|
419
|
+
const tag = d.verify.kind === 'cheap' ? '[re-run] ' : `[${d.verify.kind}]`.padEnd(12);
|
|
420
|
+
L.push(` ${tag} ${d.item}: \`${d.verify.command}\`${exp}${d.verify.kind === 'cheap' ? '' : ` — ${WHY_NOT[d.verify.kind]}`}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return L.join('\n') + '\n';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function argValue(argv, name) {
|
|
430
|
+
const i = argv.indexOf(name);
|
|
431
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const USAGE = [
|
|
435
|
+
'usage: handoff-state.mjs capture [--json] [--no-servers]',
|
|
436
|
+
' handoff-state.mjs check (--log FILE | --saved-at ISO --sha SHA [--branch B]) [--json]',
|
|
437
|
+
].join('\n');
|
|
438
|
+
|
|
439
|
+
export function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = process.stdout, err = process.stderr } = {}) {
|
|
440
|
+
const [cmd, ...rest] = argv;
|
|
441
|
+
const json = rest.includes('--json');
|
|
442
|
+
if (cmd === 'capture') {
|
|
443
|
+
const s = captureState({ cwd, probeServers: !rest.includes('--no-servers') });
|
|
444
|
+
out.write(json ? JSON.stringify(s, null, 2) + '\n' : renderRunState(s));
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
if (cmd === 'check') {
|
|
448
|
+
let savedAt = argValue(rest, '--saved-at');
|
|
449
|
+
let sha = argValue(rest, '--sha');
|
|
450
|
+
let branch = argValue(rest, '--branch');
|
|
451
|
+
let log = null;
|
|
452
|
+
const logPath = argValue(rest, '--log');
|
|
453
|
+
if (logPath) {
|
|
454
|
+
let text;
|
|
455
|
+
try { text = fs.readFileSync(logPath, 'utf8'); } catch (e) {
|
|
456
|
+
err.write(`handoff-state: cannot read ${logPath}: ${e.code || e.message}\n${USAGE}\n`);
|
|
457
|
+
return 2;
|
|
458
|
+
}
|
|
459
|
+
log = parseLog(text);
|
|
460
|
+
savedAt = savedAt || log.savedAt;
|
|
461
|
+
sha = sha || log.sha;
|
|
462
|
+
branch = branch || log.branch;
|
|
463
|
+
}
|
|
464
|
+
if (!savedAt && !sha) {
|
|
465
|
+
err.write(`handoff-state: nothing to check against — the note has no saved time or sha\n${USAGE}\n`);
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
const st = staleness({ cwd, savedAt, sha, branch });
|
|
469
|
+
out.write(json ? JSON.stringify({ staleness: st, log }, null, 2) + '\n' : renderCheck(st, log));
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
err.write(`${USAGE}\n`);
|
|
473
|
+
return 2;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (isMainModule(import.meta.url)) {
|
|
477
|
+
process.exitCode = main();
|
|
478
|
+
}
|