@codedology/code-forge 0.5.1 → 0.6.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +39 -2
  3. package/docs/README.md +5 -4
  4. package/docs/reference/blocks.json +127 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +2 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +3 -1
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/ledger.md +2 -0
  15. package/skill/references/plan.md +6 -4
  16. package/skill/references/review.md +2 -2
  17. package/skill/references/rules-core.md +4 -1
  18. package/skill/scripts/forge +1 -1
  19. package/skill/templates/plan.md +4 -3
  20. package/src/autopilot/actions.mjs +491 -0
  21. package/src/autopilot/binnacle.mjs +494 -0
  22. package/src/autopilot/delegate.mjs +422 -0
  23. package/src/autopilot/grant.mjs +354 -0
  24. package/src/autopilot/limits.mjs +848 -0
  25. package/src/autopilot/scopes.mjs +95 -0
  26. package/src/cli/author.mjs +7 -5
  27. package/src/cli/autopilot.mjs +500 -0
  28. package/src/cli/block.mjs +40 -8
  29. package/src/cli/facts.mjs +7 -6
  30. package/src/cli/jev.mjs +10 -6
  31. package/src/cli/logs.mjs +4 -142
  32. package/src/cli/plan.mjs +18 -7
  33. package/src/cli/run.mjs +20 -61
  34. package/src/cli/s2.mjs +5 -4
  35. package/src/cli/spawn.mjs +5 -4
  36. package/src/config/closed-book.mjs +20 -3
  37. package/src/config/load.mjs +113 -1
  38. package/src/doctor/probes.mjs +34 -11
  39. package/src/engines/builders/claude.mjs +3 -3
  40. package/src/engines/builders/codex.mjs +6 -5
  41. package/src/engines/builders/grok.mjs +3 -3
  42. package/src/engines/builders/validate-params.mjs +3 -3
  43. package/src/ledger/report.mjs +2 -0
  44. package/src/review/fixloop.mjs +69 -5
  45. package/src/review/gate-check.mjs +156 -6
  46. package/src/session/facts.mjs +291 -73
  47. package/src/session/plan-check.mjs +75 -6
  48. package/src/session/spawn.mjs +68 -8
  49. package/src/state/run.mjs +8 -1
  50. package/src/util/breadcrumbs.mjs +1 -0
  51. package/src/util/forbidden.mjs +10 -0
  52. package/src/util/gh.mjs +175 -0
  53. package/src/worker/engine.mjs +8 -4
  54. package/src/worker/loop.mjs +42 -0
@@ -0,0 +1,494 @@
1
+ /**
2
+ * The autopilot binnacle and full log (issue #5, plan autopilot §2 row B49a): what the owner reads
3
+ * on return, shaped like their "overnight run" doc.
4
+ *
5
+ * - {@link buildBinnacle}: the 8-part summary (title + byline, Status at a glance, Decisions,
6
+ * Blocks, Open questions, Actions only you can take, Incidents, Timeline newest first) from the
7
+ * run's ledger rows and run record. Pure: no I/O, no clock besides the `now` it is given.
8
+ * - {@link buildFullLog}: one entry per autopilot-related ledger row, newest first, each with a
9
+ * short fixed-format `detail` (never raw packet text).
10
+ * - {@link renderBinnacleMarkdown} / {@link renderLogMarkdown}: the Markdown of each.
11
+ * - {@link writeAutopilotFiles} / {@link refreshAutopilotFiles}: `autopilot-binnacle.md` and
12
+ * `autopilot-log.md` in the run dir (`~/.code-forge/runs/<run>/`, 0600), rewritten after every
13
+ * delegate decision (best effort, `delegate.mjs`).
14
+ * - {@link storeLink}: `autopilot binnacle --link <url>` — the https link of the live doc, kept in
15
+ * the active grant under a signed `autopilot.link` row.
16
+ *
17
+ * Every text leaves through the same scrub: registered secrets and secret-shaped tokens masked, the
18
+ * home dir as `~`, the working dir as `<project>`, `op://` references as `op://<ref>`.
19
+ */
20
+
21
+ import { randomBytes } from 'node:crypto';
22
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { maskSecretTokens } from '../config/secret-patterns.mjs';
26
+ import { buildSpend } from '../ledger/report.mjs';
27
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
28
+ import { StateError, assertRunId, runsDir } from '../state/paths.mjs';
29
+ import { readRun, saveRun, withRunLock, writeSigned } from '../state/run.mjs';
30
+ import { createScrubber } from '../util/error-log.mjs';
31
+ import { exec } from '../util/exec.mjs';
32
+ import { redact } from '../util/redact.mjs';
33
+ import { checkExpiry, grantState } from './grant.mjs';
34
+ import { spendByCategory } from './limits.mjs';
35
+ import { BUDGET_CATEGORIES } from './scopes.mjs';
36
+
37
+ export const BINNACLE_FILE = 'autopilot-binnacle.md';
38
+ export const LOG_FILE = 'autopilot-log.md';
39
+ export const NONE_YET = 'none yet';
40
+
41
+ /** The ledger events (besides every `autopilot.*`) that belong to the log and the timeline. */
42
+ const EXTRA_EVENTS = Object.freeze(['block.close', 'review.session_timeout', 'worker.down', 'worker.replaced', 'ledger.tamper', 'budget.refused', 'gate.red', 'gate.done']);
43
+
44
+ /** @typedef {Record<string, any>} Row */
45
+ /** @typedef {{home?: string | null, cwd?: string | null}} ScrubCtx */
46
+
47
+ /**
48
+ * The scrub every output text goes through.
49
+ * @param {ScrubCtx} [ctx] @returns {(text: string) => string}
50
+ */
51
+ export function makeScrub(ctx = {}) {
52
+ const scrubber = createScrubber({ home: ctx.home === undefined ? os.homedir() : ctx.home, cwd: ctx.cwd === undefined ? process.cwd() : ctx.cwd });
53
+ return (text) => scrubber.scrub(maskSecretTokens(redact(String(text))));
54
+ }
55
+
56
+ /** @param {unknown} value @param {(t: string) => string} scrub @returns {any} `value` with every string scrubbed. */
57
+ function deepScrub(value, scrub) {
58
+ if (typeof value === 'string') return scrub(value);
59
+ if (Array.isArray(value)) return value.map((v) => deepScrub(v, scrub));
60
+ if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepScrub(v, scrub)]));
61
+ return value;
62
+ }
63
+
64
+ /** @param {unknown} v @returns {v is string} a non-empty string. */
65
+ const str = (v) => typeof v === 'string' && v.length > 0;
66
+ /** @param {unknown} v @returns {string | null} */
67
+ const strOr = (v) => (str(v) ? v : null);
68
+ /** @param {unknown} x @returns {string} a list as `a, b`; anything but an array is empty. */
69
+ const list = (x) => (Array.isArray(x) ? x.join(', ') : '');
70
+ /** @param {...unknown} xs @returns {string | null} the first non-empty string. */
71
+ const first = (...xs) => xs.find(str) ?? null;
72
+ /** @param {Row | null | undefined} record @returns {ScrubCtx} the scrub context of a run: its workspace is the project path. */
73
+ export const scrubCtxOf = (record) => ({ cwd: str(record?.workspace) ? record?.workspace : null });
74
+ /** @param {unknown} x @param {string} [fallback] @returns {string} `x` as text, or `fallback`: no text ever says undefined or null. */
75
+ const u = (x, fallback = 'unknown') => (x === undefined || x === null || x === '' ? fallback : String(x));
76
+ /** @param {string | null | undefined} sha */
77
+ const short = (sha) => (str(sha) ? sha.slice(0, 7) : null);
78
+ /** @param {number} n */
79
+ const usd = (n) => n.toFixed(2);
80
+ /** @param {string} text @param {number} max */
81
+ const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
82
+
83
+ /**
84
+ * The rows of one run, oldest first, each with a time: its own `ts`, else the time of the row
85
+ * before it (`approx`), else null. Shadow rows are dropped.
86
+ * @param {Row[]} rows @param {string} runId
87
+ * @returns {Array<{row: Row, time: string | null, approx: boolean, index: number}>}
88
+ */
89
+ function ordered(rows, runId) {
90
+ /** @type {Array<{row: Row, time: string | null, approx: boolean, index: number}>} */
91
+ const out = [];
92
+ let last = null;
93
+ rows.forEach((row, index) => {
94
+ if (row === null || typeof row !== 'object' || row.run !== runId || row.source === 'shadow') return;
95
+ const good = str(row.ts) && Number.isFinite(Date.parse(row.ts));
96
+ if (good) last = row.ts;
97
+ out.push({ row, time: good ? row.ts : last, approx: !good, index });
98
+ });
99
+ return out;
100
+ }
101
+
102
+ /** @param {Row} row */
103
+ const isDelegateSession = (row) => row.event === 'session' && row.role === 'delegate';
104
+ /** @param {Row} row */
105
+ const inLog = (row) => (typeof row.event === 'string' && (row.event.startsWith('autopilot.') || EXTRA_EVENTS.includes(row.event))) || (row.event === 'review.waived' && row.by === 'autopilot') || isDelegateSession(row);
106
+
107
+ /**
108
+ * Event label and fixed-format detail of one row (never the packet, question or answer text).
109
+ * @param {Row} row @returns {{event: string, detail: string, link: string | null}}
110
+ */
111
+ function describe(row) {
112
+ const scope = strOr(row.scope) ?? 'unknown scope';
113
+ switch (row.event) {
114
+ case 'autopilot.grant':
115
+ return { event: 'Grant started', detail: `grant ${u(row.grant_id)} · delegate ${u(row.delegate)} · allow ${list(row.scopes)} · until ${u(row.until)}`, link: null };
116
+ case 'autopilot.stop':
117
+ return { event: 'Grant stopped', detail: `grant ${u(row.grant_id)}`, link: null };
118
+ case 'autopilot.expire':
119
+ return { event: 'Grant expired', detail: `grant ${u(row.grant_id)} · window ended ${u(row.until)}`, link: null };
120
+ case 'autopilot.link':
121
+ return { event: 'Log link stored', detail: `grant ${u(row.grant_id)}`, link: strOr(row.link) };
122
+ case 'autopilot.decision': {
123
+ const conf = typeof row.confidence === 'number' ? ` · confidence ${u(row.confidence)}` : '';
124
+ if (row.acted === true) return { event: 'Delegate decided', detail: `${scope} · acted · decision ${u(row.decision, 'no decision')}${conf} · ${u(row.decision_id)}`, link: null };
125
+ return { event: 'Question to the owner', detail: `${scope} · to owner (${row.owner_reason ?? 'unknown'})${conf} · ${u(row.decision_id)}`, link: null };
126
+ }
127
+ case 'autopilot.pause':
128
+ return { event: 'Budget category paused', detail: `${u(row.category)} · spent ${usd(Number(row.spent_usd ?? 0))} of ${usd(Number(row.cap_usd ?? 0))} USD`, link: null };
129
+ case 'autopilot.approve':
130
+ return { event: 'Owner approval', detail: `${u(row.key)} · until ${u(row.until)} · ${u(row.approval_id)}`, link: null };
131
+ case 'autopilot.restore':
132
+ return { event: 'Approval restored', detail: `${row.key ?? row.approval_id ?? 'approval'}`, link: null };
133
+ case 'autopilot.restore_skipped':
134
+ return { event: 'Restore skipped', detail: `${row.key ?? row.approval_id ?? 'approval'} · ${row.reason === 'changed-by-hand' ? 'changed by hand' : (row.reason ?? 'unknown')}`, link: null };
135
+ case 'autopilot.extra_round':
136
+ return { event: 'Extra fix round', detail: `grant ${u(row.grant_id)} · decision ${u(row.decision_id)}`, link: null };
137
+ case 'autopilot.level':
138
+ return { event: 'Coder level chosen', detail: `${u(row.level)} · lane ${row.lane ?? 'none'} · plan ${row.plan_level ?? 'none'} · decision ${u(row.decision_id)}`, link: null };
139
+ case 'autopilot.issue_failed':
140
+ return { event: 'Waiver issue failed', detail: `finding ${u(row.finding, 'unknown finding')} · no issue opened`, link: null };
141
+ case 'review.waived':
142
+ return { event: 'Waiver by autopilot', detail: `finding ${u(row.finding, 'unknown finding')} · ${row.severity ?? 'unknown severity'} · issue ${row.issue ?? 'none'} · decision ${u(row.decision_id)}`, link: strOr(row.issue) };
143
+ case 'session':
144
+ return { event: 'Delegate session', detail: `${row.level ?? 'level unknown'} · ${row.provider ?? 'provider unknown'} · status ${row.status ?? 'unknown'}`, link: null };
145
+ case 'block.close':
146
+ return { event: 'Block closed', detail: `status ${row.status ?? 'unknown'}${short(first(row.commit, row.sha, row.commit_sha)) ? ` · commit ${short(first(row.commit, row.sha, row.commit_sha))}` : ''}`, link: first(row.pr_url, row.pr) };
147
+ case 'review.session_timeout':
148
+ return { event: 'Review session timed out', detail: `${row.lens ?? row.role ?? 'session'} · attempt ${u(row.attempt)} · ${row.retried === true ? 'retried' : 'gave up'}`, link: null };
149
+ case 'worker.down':
150
+ return { event: 'Worker down', detail: 'the pinned worker is not running', link: null };
151
+ case 'worker.replaced':
152
+ return { event: 'Worker replaced', detail: 'the pinned worker was replaced by another process', link: null };
153
+ case 'ledger.tamper':
154
+ return { event: 'Ledger check failed', detail: `block ${row.block ?? 'unknown'} stopped`, link: null };
155
+ case 'budget.refused':
156
+ return { event: 'Budget refused a session', detail: `${row.role ?? 'session'} · spent ${typeof row.spent_usd === 'number' ? usd(row.spent_usd) : 'unknown'} of ${typeof row.budget_usd === 'number' ? usd(row.budget_usd) : 'unknown'} USD`, link: null };
157
+ case 'gate.red':
158
+ return { event: 'Gate red', detail: `${row.gate ?? 'gate'} · exit ${row.code ?? 'unknown'}`, link: null };
159
+ case 'gate.done':
160
+ return { event: 'Gates finished', detail: `${row.stack ?? 'stack'} · ${row.all_ok === true ? 'all green' : 'not all green'}`, link: null };
161
+ default:
162
+ return { event: String(row.event), detail: '', link: null };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One entry per autopilot-related ledger row of the run, newest first (ties: the later row first).
168
+ * @param {{runId: string, rows: Row[], scrubCtx?: ScrubCtx}} opts
169
+ * @returns {Array<{time: string | null, event: string, block: string | null, file: string | null, detail: string, link: string | null}>}
170
+ */
171
+ export function buildFullLog({ runId, rows, scrubCtx }) {
172
+ const scrub = makeScrub(scrubCtx);
173
+ const mine = ordered(rows, runId).filter((x) => inLog(x.row));
174
+ mine.sort((a, b) => (Date.parse(b.time ?? '') || 0) - (Date.parse(a.time ?? '') || 0) || b.index - a.index);
175
+ return mine.map(({ row, time }) => {
176
+ const d = describe(row);
177
+ return deepScrub({ time, event: d.event, block: strOr(row.block), file: strOr(row.file), detail: d.detail, link: d.link }, scrub);
178
+ });
179
+ }
180
+
181
+ /** @param {Row} row @returns {string} the subject line of an event, e.g. ` · b1 · src/a.mjs`. */
182
+ const where = (row) => `${str(row.block) ? ` · ${u(row.block)}` : ''}${str(row.file) ? ` · ${u(row.file)}` : ''}`;
183
+
184
+ /**
185
+ * @param {Row[]} blockRows rows of one block @param {Row | undefined} entry its run-record entry
186
+ * @param {string} id
187
+ */
188
+ function blockLine(blockRows, entry, id) {
189
+ const last = (/** @type {(r: Row) => boolean} */ f) => [...blockRows].reverse().find(f);
190
+ const pick = (/** @type {string[]} */ keys) => {
191
+ for (const r of [...blockRows].reverse()) for (const k of keys) if (str(r[k])) return /** @type {string} */ (r[k]);
192
+ return null;
193
+ };
194
+ const branch = pick(['branch']);
195
+ const pr = pick(['pr_url', 'pr']);
196
+ const sha = short(pick(['commit', 'commit_sha', 'sha']));
197
+ const files = (/** @type {string} */ ev) => new Set(blockRows.filter((r) => r.event === ev && str(r.file)).map((r) => r.file)).size;
198
+ const results = blockRows.filter((r) => r.event === 'review.result').length;
199
+ const review = results + files('review.approved') + files('review.waived') === 0 ? 'none yet' : `${results} result${results === 1 ? '' : 's'} · ${files('review.approved')} approved · ${files('review.waived')} waived`;
200
+ const proofs = blockRows.filter((r) => r.event === 'proof');
201
+ const tests = proofs.length === 0 ? 'not recorded' : `${proofs.length} proof run${proofs.length === 1 ? '' : 's'}${str(proofs[proofs.length - 1].isolation) ? `, last ${proofs[proofs.length - 1].isolation}` : ''}`;
202
+ const gateRow = last((r) => r.event === 'gate.done');
203
+ const closed = blockRows.some((r) => r.event === 'block.close');
204
+ const gates = gateRow ? (gateRow.all_ok === true ? 'green' : 'red') : closed ? 'passed at close' : 'none yet';
205
+ const status = entry?.status ?? (closed ? 'closed' : 'unknown');
206
+ const state = status === 'closed' ? 'Closed, ready for you to mark ready and merge' : status === 'open' ? 'Open' : status === 'stopped' ? 'Stopped' : String(status);
207
+ return { block: id, branch: [branch, sha ? `commit ${sha}` : null, pr].filter((x) => x !== null).join(' · ') || 'not recorded', review, tests, gates, state };
208
+ }
209
+
210
+ /**
211
+ * The binnacle data. See the module doc.
212
+ * @param {{runId: string, rows: Row[], record: Row, now: Date, scrubCtx?: ScrubCtx, owner?: string | null}} opts - `owner`: the byline's owner (see {@link gitOwner}), null when unknown.
213
+ */
214
+ export function buildBinnacle({ runId, rows, record, now, scrubCtx = scrubCtxOf(record), owner = null }) {
215
+ const scrub = makeScrub(scrubCtx);
216
+ const mine = ordered(rows, runId);
217
+ const all = mine.map((x) => x.row);
218
+ const grant = record?.autopilot && typeof record.autopilot === 'object' ? record.autopilot : null;
219
+ const grantRow = [...all].reverse().find((r) => r.event === 'autopilot.grant');
220
+ const linkRow = [...all].reverse().find((r) => r.event === 'autopilot.link');
221
+ const link = strOr(grant?.link) ?? strOr(linkRow?.link);
222
+
223
+ // ---- blocks
224
+ const blocksRec = /** @type {Record<string, Row>} */ (record?.blocks && typeof record.blocks === 'object' ? record.blocks : {});
225
+ const ids = [...new Set([...Object.keys(blocksRec), ...all.filter((r) => r.event === 'block.close' && str(r.block)).map((r) => r.block)])].sort((a, b) => String(blocksRec[a]?.opened_at ?? '').localeCompare(String(blocksRec[b]?.opened_at ?? '')) || a.localeCompare(b));
226
+ const blocks = ids.map((id) =>
227
+ blockLine(
228
+ all.filter((r) => r.block === id),
229
+ blocksRec[id],
230
+ id,
231
+ ),
232
+ );
233
+ const closedIds = ids.filter((id) => (blocksRec[id]?.status ?? (all.some((r) => r.event === 'block.close' && r.block === id) ? 'closed' : '')) === 'closed');
234
+
235
+ // ---- decisions
236
+ /** @type {Array<{time: string | null, decision: string, why: string, reverse: string}>} */
237
+ const decisions = [];
238
+ const stopCmd = `code-forge autopilot stop --run ${runId}`;
239
+ for (const { row, time } of mine) {
240
+ const r = row;
241
+ if (r.event === 'autopilot.decision') {
242
+ if (r.acted === true) decisions.push({ time, decision: `${u(r.scope, 'unknown scope')}: the delegate chose "${u(r.decision, 'no decision')}"${where({ block: r.subject?.block, file: r.subject?.file })}`, why: `${r.reason ?? 'no reason recorded'} (${typeof r.confidence === 'number' ? `confidence ${r.confidence}` : 'no confidence'})`, reverse: `Overrule it: undo the action it allowed (see its own entry) and, to stop further ones, run ${stopCmd}` });
243
+ else decisions.push({ time, decision: `Asked the owner (${u(r.scope, 'unknown scope')}): ${r.question ?? 'question not recorded'}`, why: `Not acted on: ${r.failure ?? r.owner_reason ?? 'unknown'}`, reverse: 'Nothing was done; answer it yourself (see Open questions).' });
244
+ } else if (r.event === 'review.waived' && r.by === 'autopilot') {
245
+ decisions.push({ time, decision: `Waived ${u(r.finding, 'unknown finding')} (${r.severity ?? 'unknown severity'}) in ${u(r.file, 'unknown file')}, block ${u(r.block, 'unknown block')}`, why: `${r.reason ?? 'no reason recorded'} (decision ${u(r.decision_id)})`, reverse: `Reopen the finding: fix it in ${u(r.file, 'unknown file')}, then delete the waiver issue (${r.issue ?? 'none was opened'})` });
246
+ } else if (r.event === 'autopilot.extra_round') {
247
+ decisions.push({ time, decision: `One extra fix round for ${u(r.file, 'unknown file')}, block ${u(r.block, 'unknown block')}`, why: `The delegate allowed it (decision ${u(r.decision_id)})`, reverse: 'The round is spent; refuse further ones by stopping the grant or denying round:extra.' });
248
+ } else if (r.event === 'autopilot.level') {
249
+ decisions.push({ time, decision: `Block ${u(r.block, 'unknown block')} codes at ${u(r.level)}`, why: `Lane ${r.lane ?? 'none'}, plan level ${r.plan_level ?? 'none'} (decision ${u(r.decision_id)})`, reverse: 'Open the block again with code-forge block open --level <other level>.' });
250
+ } else if (r.event === 'autopilot.approve') {
251
+ decisions.push({ time, decision: `You approved ${u(r.key)} until ${u(r.until)}`, why: `Owner approval ${u(r.approval_id)}`, reverse: `It restores itself at ${u(r.until)}; or set the old value back in .code-forge.yml and run code-forge run reload.` });
252
+ }
253
+ }
254
+
255
+ // ---- open questions and owner actions
256
+ const toOwner = mine.filter((x) => x.row.event === 'autopilot.decision' && x.row.acted !== true).map((x) => x.row);
257
+ const questions = toOwner.map((r) => `${r.question ?? 'question not recorded'} (${u(r.scope, 'unknown scope')}, ${r.owner_reason ?? 'unknown'})`);
258
+ /** @type {Array<{text: string, done: boolean}>} */
259
+ const ownerActions = toOwner.map((r) => ({ text: `Answer: ${r.question ?? 'question not recorded'} (${u(r.scope, 'unknown scope')})`, done: false }));
260
+ // criticals are never delegated: per (block, file) the LATEST review.done decides; a critical there
261
+ // stays an owner action until a later approval of that file or a human waiver clears it
262
+ /** @type {Map<string, {row: Row, index: number}>} */
263
+ const latestDone = new Map();
264
+ for (const { row, index } of mine) if (row.event === 'review.done') latestDone.set(`${row.block}\0${row.file}`, { row, index });
265
+ for (const { row, index } of latestDone.values()) {
266
+ if (!(Number(row.findings_by_severity?.critical) > 0)) continue;
267
+ const cleared = mine.some((y) => y.index > index && y.row.block === row.block && y.row.file === row.file && (y.row.event === 'review.approved' || (y.row.event === 'review.waived' && y.row.by === 'human')));
268
+ if (!cleared) ownerActions.push({ text: `Decide the critical finding in ${u(row.file, 'unknown file')} (block ${u(row.block, 'unknown block')}): critical findings are never delegated`, done: false });
269
+ }
270
+ const paused = grant ? Object.keys(grant.paused && typeof grant.paused === 'object' ? grant.paused : {}).sort() : [...new Set(all.filter((r) => r.event === 'autopilot.pause').map((r) => r.category))];
271
+ for (const c of paused) {
272
+ ownerActions.push({ text: `Budget ${u(c, 'unknown category')} is paused: raise the cap or leave it (only you can raise a budget)`, done: false });
273
+ const p = grant ? grant.paused?.[c] : all.find((r) => r.event === 'autopilot.pause' && r.category === c);
274
+ if (p) questions.push(`Budget ${u(c, 'unknown category')} stopped at ${usd(Number(p.spent_usd ?? 0))} of ${usd(Number(p.cap_usd ?? 0))} USD: raise the cap or leave it?`);
275
+ }
276
+ for (const r of all.filter((x) => x.event === 'autopilot.restore_skipped')) ownerActions.push({ text: `Check ${r.key ?? r.approval_id ?? 'the approval'}: it was changed by hand, so it was not restored`, done: false });
277
+ for (const id of closedIds) {
278
+ const pr = first(...[...all].reverse().filter((r) => r.block === id).map((r) => first(r.pr_url, r.pr)));
279
+ ownerActions.push({ text: `Mark block ${id} ready and merge it${pr ? ` (${pr})` : ''}`, done: false });
280
+ }
281
+
282
+ // ---- incidents
283
+ /** @type {Array<{time: string | null, what: string, effect: string, fix: string}>} */
284
+ const incidents = [];
285
+ for (const { row, time } of mine) {
286
+ const r = row;
287
+ if (r.event === 'review.session_timeout') incidents.push({ time, what: `A ${r.lens ?? r.role ?? 'review'} session timed out (attempt ${u(r.attempt)})`, effect: r.retried === true ? 'It was retried.' : 'No retry left; the review was marked unavailable.', fix: r.retried === true ? 'None needed unless it repeats.' : 'Run the review again.' });
288
+ else if (r.event === 'worker.down' || r.event === 'worker.replaced') incidents.push({ time, what: r.event === 'worker.down' ? 'The pinned worker was down' : 'The pinned worker was replaced', effect: 'Reviews wait until the worker is back.', fix: 'Restart the worker for this run.' });
289
+ else if (r.event === 'ledger.tamper') incidents.push({ time, what: `A ledger check failed for block ${r.block ?? 'unknown'}`, effect: 'The block was stopped.', fix: 'Look at the block before reopening it.' });
290
+ else if (r.event === 'autopilot.issue_failed') incidents.push({ time, what: `The waiver issue for ${u(r.finding, 'unknown finding')} in ${u(r.file, 'unknown file')} could not be opened`, effect: 'The waiver stands without an issue.', fix: 'Open the tracking issue by hand.' });
291
+ else if (r.event === 'budget.refused') incidents.push({ time, what: `A ${r.role ?? 'session'} session was refused: budget ${typeof r.spent_usd === 'number' ? usd(r.spent_usd) : 'unknown'} of ${typeof r.budget_usd === 'number' ? usd(r.budget_usd) : 'unknown'} USD`, effect: 'The session did not run.', fix: 'Raise the budget (only you can) or finish by hand.' });
292
+ else if (r.event === 'autopilot.pause') incidents.push({ time, what: `Budget category ${u(r.category)} reached its stop at ${usd(Number(r.spent_usd ?? 0))} of ${usd(Number(r.cap_usd ?? 0))} USD`, effect: `Delegated actions in ${u(r.category)} stopped.`, fix: 'Raise the cap or leave it paused.' });
293
+ }
294
+
295
+ // ---- timeline (newest first)
296
+ const timeline = buildFullLog({ runId, rows, scrubCtx }).map((e) => ({ time: e.time, event: `${e.event}${e.block ? ` · ${e.block}` : ''}${e.file ? ` · ${e.file}` : ''}${e.detail ? ` — ${e.detail}` : ''}` }));
297
+
298
+ // ---- status
299
+ const state = grant ? (checkedState(grant, record, now)) : 'no grant';
300
+ const waived = all.filter((r) => r.event === 'review.waived' && r.by === 'autopilot').length;
301
+ const approved = new Set(all.filter((r) => r.event === 'review.approved').map((r) => `${u(r.block, 'unknown block')}\0${u(r.file, 'unknown file')}`)).size;
302
+ const results = all.filter((r) => r.event === 'review.result').length;
303
+ const prs = [...new Set(all.map((r) => first(r.pr_url, r.pr)).filter(str))];
304
+ const fullSuite = [...all].reverse().find((r) => r.event === 'gate.done');
305
+ const readFiles = [...new Set([...all.filter((r) => (r.event === 'review.waived' && r.by === 'autopilot') || r.event === 'autopilot.extra_round').map((r) => r.file), ...toOwner.map((r) => r.subject?.file)].filter(str))];
306
+ const baseSha = ids.map((id) => blocksRec[id]?.base_sha).find(str);
307
+ const approx = mine.filter((x) => x.approx).length;
308
+ const caps = grant?.caps ?? grantRow?.caps ?? {};
309
+ const spendBy = grant ? spendByCategory(all, runId, grant) : null;
310
+ const total = buildSpend(all).total;
311
+ const spendText = spendBy
312
+ ? BUDGET_CATEGORIES.map((c) => (typeof caps[c] === 'number' ? `${c} ${usd(spendBy[c] ?? 0)} of ${usd(caps[c])} USD` : `${c} ${usd(spendBy[c] ?? 0)} USD (no cap)`)).join(', ')
313
+ : `${usd(total.usd)} USD in total`;
314
+ const status = [
315
+ { item: 'Result', value: ids.length === 0 ? 'no blocks yet' : `${closedIds.length} of ${ids.length} block${ids.length === 1 ? '' : 's'} closed` },
316
+ { item: 'Stack/PRs', value: prs.length === 0 ? 'none recorded' : prs.join(', ') },
317
+ { item: 'Full suite', value: fullSuite ? (fullSuite.all_ok === true ? 'green' : 'red') : 'not recorded' },
318
+ { item: 'Reviews', value: `${results} result${results === 1 ? '' : 's'} · ${approved} file${approved === 1 ? '' : 's'} approved · ${waived} waived by autopilot` },
319
+ { item: 'Files to read', value: readFiles.length === 0 ? 'none' : `${readFiles.slice(0, 5).join(', ')}${readFiles.length > 5 ? ` and ${readFiles.length - 5} more` : ''}` },
320
+ { item: 'Base', value: short(baseSha) ?? 'unknown' },
321
+ { item: 'Spend', value: spendText },
322
+ { item: 'Autopilot window', value: grant ? `${state} · grant ${u(grant.grant_id)} · ${u(grant.started_at)} to ${u(grant.until)} · delegate ${u(grant.delegate)} · allow ${list(grant.scopes)}` : grantRow ? `grant ${u(grantRow.grant_id)} · until ${u(grantRow.until)}` : 'no grant' },
323
+ { item: 'Log link', value: link ?? 'none' },
324
+ ...(approx > 0 ? [{ item: 'Clock note', value: approx === 1 ? '1 row carries no valid timestamp and takes the time of the row before it' : `${approx} rows carry no valid timestamp and take the time of the rows before them` }] : []),
325
+ ];
326
+
327
+ const out = {
328
+ title: `Autopilot run ${runId}`,
329
+ byline: { date: now.toISOString().slice(0, 10), owner: str(owner) ? owner : null },
330
+ status,
331
+ decisions,
332
+ blocks,
333
+ open_questions: questions,
334
+ owner_actions: ownerActions,
335
+ incidents,
336
+ timeline,
337
+ };
338
+ return deepScrub(out, scrub);
339
+ }
340
+
341
+ /** @param {Row} grant @param {Row} record @param {Date} now @returns {string} */
342
+ function checkedState(grant, record, now) {
343
+ if (record?.status && record.status !== 'active') return 'run ended';
344
+ return grantState(/** @type {import("./grant.mjs").Grant} */ (grant), now);
345
+ }
346
+
347
+ /** @param {unknown} text @returns {string} one table cell: no pipe, no line break. */
348
+ const cell = (text) => String(text ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
349
+
350
+ /**
351
+ * @param {string[]} head @param {string[][]} body @returns {string[]} the lines of a pipe table, or `none yet`.
352
+ */
353
+ function table(head, body) {
354
+ if (body.length === 0) return [NONE_YET, ''];
355
+ return [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`, ...body.map((r) => `| ${r.map(cell).join(' | ')} |`), ''];
356
+ }
357
+
358
+ /**
359
+ * The binnacle as Markdown: title, byline, then the 7 sections in the doc's order.
360
+ * @param {ReturnType<typeof buildBinnacle>} b @param {ScrubCtx} [scrubCtx] @returns {string}
361
+ */
362
+ export function renderBinnacleMarkdown(b, scrubCtx) {
363
+ const scrub = makeScrub(scrubCtx);
364
+ const lines = [`# ${b.title}`, '', `${b.byline.date} · ${b.byline.owner ?? 'owner not set'}`, ''];
365
+ lines.push('## Status at a glance', '', ...table(['Item', 'Value'], b.status.map((s) => [s.item, s.value])));
366
+ lines.push('## Decisions', '', ...table(['Time', 'Decision', 'Why', 'What would reverse it'], b.decisions.map((d) => [d.time ?? '', d.decision, d.why, d.reverse])));
367
+ lines.push('## Blocks', '', ...table(['Block', 'Branch / PR', 'Review', 'Tests', 'Gates', 'State'], b.blocks.map((x) => [x.block, x.branch, x.review, x.tests, x.gates, x.state])));
368
+ lines.push('## Open questions', '', ...(b.open_questions.length === 0 ? [NONE_YET] : b.open_questions.map((q, i) => `${i + 1}. ${cell(q)}`)), '');
369
+ lines.push('## Actions only you can take', '', ...(b.owner_actions.length === 0 ? [NONE_YET] : b.owner_actions.map((a) => `- [${a.done ? 'x' : ' '}] ${cell(a.text)}`)), '');
370
+ lines.push('## Incidents', '', ...table(['Time', 'What', 'Effect', 'Fix'], b.incidents.map((i) => [i.time ?? '', i.what, i.effect, i.fix])));
371
+ lines.push('## Timeline', '', ...(b.timeline.length === 0 ? [NONE_YET] : b.timeline.map((t) => `- ${t.time ?? 'unknown time'} — ${cell(t.event)}`)), '');
372
+ return scrub(`${lines.join('\n').replace(/\n+$/, '')}\n`);
373
+ }
374
+
375
+ /**
376
+ * The full log as Markdown: one table, newest first.
377
+ * @param {{runId: string, entries: ReturnType<typeof buildFullLog>}} opts @param {ScrubCtx} [scrubCtx]
378
+ * @returns {string}
379
+ */
380
+ export function renderLogMarkdown({ runId, entries }, scrubCtx) {
381
+ const scrub = makeScrub(scrubCtx);
382
+ const lines = [`# Autopilot full log ${runId}`, '', 'Newest first.', '', ...table(['Time', 'Event', 'Block', 'File', 'Detail', 'Link'], entries.map((e) => [e.time ?? '', e.event, e.block ?? '', e.file ?? '', e.detail, e.link ?? '']))];
383
+ return scrub(`${lines.join('\n').replace(/\n+$/, '')}\n`);
384
+ }
385
+
386
+ /**
387
+ * The byline's owner: the workspace's `git config user.name` (the owner's own local setting), best
388
+ * effort: any failure, a missing name or a non-repo gives null. `GIT_*` variables of a calling hook
389
+ * are dropped so the answer is the workspace's.
390
+ * @param {unknown} workspace @returns {Promise<string | null>}
391
+ */
392
+ export async function gitOwner(workspace) {
393
+ if (!str(workspace)) return null;
394
+ try {
395
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
396
+ const res = await exec(['git', '-C', workspace, 'config', 'user.name'], { env, timeoutMs: 10000, okExitCodes: [0, 1] });
397
+ const name = res.result === 'ok' && res.code === 0 ? res.stdout.trim() : '';
398
+ return name.length > 0 && name.length <= 200 ? name : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /** @param {string} runId @returns {string} the run dir the Markdown files live in. */
405
+ export const runFilesDir = (runId) => path.join(runsDir(), assertRunId(runId));
406
+
407
+ /**
408
+ * Write `text` to `<run dir>/<name>` atomically (0600).
409
+ * @param {string} runId @param {string} name @param {string} text @returns {string} the path
410
+ */
411
+ function writeFileIn(runId, name, text) {
412
+ const dir = runFilesDir(runId);
413
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
414
+ const file = path.join(dir, name);
415
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
416
+ try {
417
+ writeFileSync(tmp, text, { mode: 0o600 });
418
+ renameSync(tmp, file);
419
+ } catch (err) {
420
+ rmSync(tmp, { force: true }); // never leave the temp file behind
421
+ throw err;
422
+ }
423
+ return file;
424
+ }
425
+
426
+ /**
427
+ * Write both Markdown files for the run from `rows` and `record`.
428
+ * @param {{runId: string, rows: Row[], record: Row, now: Date, scrubCtx?: ScrubCtx, owner?: string | null}} opts
429
+ * @returns {{binnacle: string, log: string}} the two paths
430
+ */
431
+ export function writeAutopilotFiles({ runId, rows, record, now, scrubCtx = scrubCtxOf(record), owner = null }) {
432
+ const binnacle = writeFileIn(runId, BINNACLE_FILE, renderBinnacleMarkdown(buildBinnacle({ runId, rows, record, now, scrubCtx, owner }), scrubCtx));
433
+ const log = writeFileIn(runId, LOG_FILE, renderLogMarkdown({ runId, entries: buildFullLog({ runId, rows, scrubCtx }) }, scrubCtx));
434
+ return { binnacle, log };
435
+ }
436
+
437
+ /**
438
+ * Regenerate both files from the run's ledger. Best effort for callers that must never fail
439
+ * (the delegate): this one throws, they catch.
440
+ * @param {string} runId @param {{now?: Date, readRows?: (slug: string) => Promise<Row[]>}} [deps]
441
+ */
442
+ export async function refreshAutopilotFiles(runId, deps = {}) {
443
+ const record = await readRun(runId);
444
+ const rows = await (deps.readRows ?? readAllRows)(record.project);
445
+ return writeAutopilotFiles({ runId, rows, record, now: deps.now ?? new Date(), owner: await gitOwner(record.workspace) });
446
+ }
447
+
448
+ const LINK_MAX = 2048;
449
+
450
+ /**
451
+ * `--link`: the `https` URL of the live doc. Plain URLs only: no credentials, no secret-shaped
452
+ * text, at most {@link LINK_MAX} characters.
453
+ * @param {unknown} text @returns {string}
454
+ * @throws {StateError} `usage`
455
+ */
456
+ export function parseLink(text) {
457
+ if (typeof text !== 'string' || text.length === 0 || text.length > LINK_MAX) throw new StateError('usage', `--link must be an https URL of at most ${LINK_MAX} characters`);
458
+ /** @type {URL} */
459
+ let url;
460
+ try {
461
+ url = new URL(text);
462
+ } catch {
463
+ throw new StateError('usage', '--link must be an https URL, e.g. https://claude.ai/…');
464
+ }
465
+ if (url.protocol !== 'https:' || url.hostname === '') throw new StateError('usage', '--link must be an https URL; http and other schemes are refused');
466
+ if (url.username !== '' || url.password !== '') throw new StateError('usage', '--link must not carry credentials');
467
+ const link = url.href;
468
+ if (maskSecretTokens(redact(link)) !== link) throw new StateError('usage', '--link looks like it carries a secret; give a plain URL');
469
+ return link;
470
+ }
471
+
472
+ /**
473
+ * Store the doc link in the ACTIVE grant: the signed `autopilot.link` row first, then the grant.
474
+ * @param {{runId: string, link: unknown, now?: Date, writeRow?: (row: Row) => Promise<unknown>}} opts
475
+ * @returns {Promise<string>} the stored link
476
+ * @throws {StateError} `usage` for a bad link; `no-grant`, `grant-stopped`, `grant-expired`, `run-ended`
477
+ */
478
+ export async function storeLink({ runId, link, now = new Date(), writeRow }) {
479
+ const url = parseLink(link);
480
+ await checkExpiry(runId, now, { writeRow });
481
+ return withRunLock(runId, async () => {
482
+ const record = await readRun(runId);
483
+ const grant = record.autopilot;
484
+ if (!grant || typeof grant !== 'object') throw new StateError('no-grant', `run ${runId} has no autopilot grant`);
485
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
486
+ const state = grantState(grant, now);
487
+ if (state !== 'active') throw new StateError(`grant-${state}`, `run ${runId}'s autopilot grant is ${state}; the link goes into an active grant`);
488
+ const write = writeRow ?? ((/** @type {Row} */ row) => appendRow(row, { slug: record.project }));
489
+ await writeSigned(runId, write, { event: 'autopilot.link', grant_id: grant.grant_id, link: url, ts: now.toISOString() });
490
+ grant.link = url;
491
+ await saveRun(record);
492
+ return url;
493
+ });
494
+ }