@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,422 @@
1
+ /**
2
+ * The autopilot delegate (issue #5, plan autopilot §1, block B46): a closed-book session that
3
+ * answers ONE owner-level question while the owner is away, inside the run's autopilot grant.
4
+ *
5
+ * Built on the System 2 pattern (`session/s2.mjs`): a bounded packet → one fresh session in an
6
+ * empty cwd with no tools (role `delegate`, never Codex — `config/closed-book.mjs`) → the answer
7
+ * re-validated against the source schema → the packet removed.
8
+ *
9
+ * 1. `grantFor(run, scope, now)` FIRST (B45). A refusal (no grant, run ended, stopped, expired,
10
+ * denied, not allowed, a B48 check) returns `{answered: false, to_owner: true, reason}` —
11
+ * no session, no row: a run with no grant behaves exactly as before.
12
+ * 2. The packet: the scope asked, the grant's allowed scopes, the fixed deny list (text), the
13
+ * question, the options and a bounded context — every caller-given text passes `redact` and
14
+ * the secret-shape mask before it is written anywhere. At most
15
+ * {@link DELEGATE_MAX_PROMPT_TOKENS} estimated tokens (the context is cut on a UTF-8 boundary).
16
+ * 3. One session at the grant's delegate level (L2 | L3), schema {@link DELEGATE_SCHEMA}.
17
+ * 4. Exactly ONE signed `autopilot.decision` row, also when the session failed (`failure` says
18
+ * why: `<code>: <message>` for a spawner refusal, `session <status> (…)` for a failed
19
+ * session, `internal-error` — no message text — for anything else, e.g. the packet could not
20
+ * be written). The row is written BEFORE the answer is returned; only a row that cannot be
21
+ * written throws, so nothing acts unrecorded (fail closed).
22
+ *
23
+ * B47: every decision row carries a stable `decision_id` (`ad-<12 hex>`) and the `subject` the
24
+ * question is about (`{block, file, finding}`, each optional; null when none was given) — a
25
+ * delegated action (`autopilot waive|round|level`) names the decision that allowed it, and the
26
+ * action and the gate check that decision's grant, scope and subject (`review/gate-check.mjs`).
27
+ *
28
+ * Options: each is trimmed and scrubbed, THEN deduplicated; a list given with fewer than 2
29
+ * distinct options is a usage error. An ask WITHOUT options never acts: the delegate can only
30
+ * pick between choices the caller named, so an open question is answered for the owner's
31
+ * information and goes to the owner (`owner_reason: 'no-options'`).
32
+ *
33
+ * `acted` is true only when options were given, the answer is `within_scope`, does not
34
+ * `escalate`, has `confidence` ≥ `autopilot.min_confidence` (inclusive; default
35
+ * {@link DEFAULT_MIN_CONFIDENCE}), names one of the options, and the grant still allows the
36
+ * scope once the answer is in (the window may have closed during the session). Anything else
37
+ * goes to the owner (`to_owner: true`, `owner_reason` names the first rule that failed). The
38
+ * delegate never acts itself: the caller (B47) acts on `acted: true` only.
39
+ *
40
+ * A config the run cannot provide (no snapshot and no fallback; a snapshot whose hash does not
41
+ * match) throws before any session, with its own code (`no-config`, `config-snapshot`).
42
+ *
43
+ * `autopilot.min_confidence` is read from the run's config snapshot (the config the run started
44
+ * or was last reloaded with), so a workspace file edited mid-run does not lower it.
45
+ */
46
+
47
+ import { randomBytes } from 'node:crypto';
48
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
49
+ import path from 'node:path';
50
+ import { maskSecretTokens } from '../config/secret-patterns.mjs';
51
+ import { appendRow } from '../ledger/write.mjs';
52
+ import { estimateTokens, matchesSchema, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
53
+ import { snapshotFor } from '../state/config-snapshot.mjs';
54
+ import { StateError } from '../state/paths.mjs';
55
+ import { readRun, writeSigned } from '../state/run.mjs';
56
+ import { redact } from '../util/redact.mjs';
57
+ import { refreshAutopilotFiles } from './binnacle.mjs';
58
+ import { grantFor } from './grant.mjs';
59
+ import { ALLOWABLE_WHAT, FIXED_DENY } from './scopes.mjs';
60
+
61
+ /** Upper bound of the delegate prompt, in estimated tokens (bytes / 4) — as S2's. */
62
+ export const DELEGATE_MAX_PROMPT_TOKENS = 4000;
63
+
64
+ /** Maximum length of the answer's `reason`, in characters. */
65
+ export const DELEGATE_REASON_MAX = 400;
66
+
67
+ /** The question as stored in the decision row is cut to this many UTF-8 bytes. */
68
+ export const DECISION_QUESTION_MAX_BYTES = 1000;
69
+
70
+ /** `autopilot.min_confidence` when the config does not set it. */
71
+ export const DEFAULT_MIN_CONFIDENCE = 0.7;
72
+
73
+ /** At most this many options, each at most {@link OPTION_MAX_BYTES} bytes. */
74
+ export const MAX_OPTIONS = 20;
75
+ export const OPTION_MAX_BYTES = 200;
76
+
77
+ /** The delegate answer schema (source form; `compileSchema` makes the OpenAI strict variant). */
78
+ export const DELEGATE_SCHEMA = Object.freeze({
79
+ type: 'object',
80
+ properties: {
81
+ decision: { type: 'string' },
82
+ within_scope: { type: 'boolean' },
83
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
84
+ reason: { type: 'string' },
85
+ escalate: { type: 'boolean' },
86
+ },
87
+ required: ['decision', 'within_scope', 'confidence', 'reason', 'escalate'],
88
+ additionalProperties: false,
89
+ });
90
+
91
+ /** The lens preamble (system prompt) of every delegate session. */
92
+ export const DELEGATE_LENS =
93
+ 'You are the autopilot delegate for code-forge: you answer one question for the project owner while they are away, ' +
94
+ 'and only inside the scope the owner granted. You have no tools and no files; everything you may use is in the message. ' +
95
+ 'Answer once, with a JSON object that matches the schema. Never invent facts that are not in the message. ' +
96
+ 'When the question is outside the scope, touches anything on the deny list, or you are unsure, set escalate to true.';
97
+
98
+ const HEADER = [
99
+ '# Autopilot delegate decision',
100
+ '',
101
+ 'The owner is away and granted you the scopes below until a fixed time. Decide the question from the',
102
+ 'material given. Reply with ONE JSON object:',
103
+ `{"decision": string (exactly one of the options when options are listed), "within_scope": boolean`,
104
+ ` (true only when answering decides nothing beyond the scope asked), "confidence": number 0..1,`,
105
+ ` "reason": string of at most ${DELEGATE_REASON_MAX} characters, "escalate": boolean (true hands the question back to the owner)}.`,
106
+ '',
107
+ ].join('\n');
108
+
109
+ /** @typedef {import('./grant.mjs').Grant} Grant */
110
+ /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
111
+ /**
112
+ * @typedef {import('../session/spawn.mjs').SessionDeps & {
113
+ * writeRow?: WriteRow, checks?: import('./grant.mjs').GrantCheck[], now?: () => Date,
114
+ * afterDecision?: (runId: string) => Promise<unknown>,
115
+ * }} DelegateDeps - `writeRow` receives the session rows and the decision row; `now` is the clock;
116
+ * `afterDecision` (B49a) runs after the decision row is written (default: rewrite the run's
117
+ * binnacle and log files from the ledger, only when the rows go to the real ledger).
118
+ */
119
+ /**
120
+ * @typedef {object} DelegateAsk
121
+ * @property {string} runId
122
+ * @property {string} scope - one of the allowable scopes (`waive:nit`, …).
123
+ * @property {string} question
124
+ * @property {string} [context]
125
+ * @property {string[]} [options]
126
+ * @property {{block?: string, file?: string, finding?: string}} [subject] - what the question is about (B47).
127
+ * @property {Record<string, any>} [cfg] - used only when the run record holds no config snapshot.
128
+ * @property {number} [timeoutMs]
129
+ */
130
+ /**
131
+ * @typedef {{
132
+ * answered: boolean, acted: boolean, to_owner: boolean, reason: string | null,
133
+ * grant_id: string | null, answer: Record<string, any> | null, row: Record<string, any> | null,
134
+ * }} DelegateResult - `reason`: the refusal (not answered), the failure, or why it goes to the owner.
135
+ */
136
+
137
+ /** @param {string} text @returns {string} `text` with registered secrets and secret-shaped tokens masked. */
138
+ export const scrub = (text) => maskSecretTokens(redact(text));
139
+
140
+ /**
141
+ * Cut `text` to at most `maxBytes` UTF-8 bytes without splitting a character.
142
+ * @param {string} text @param {number} maxBytes
143
+ */
144
+ function cutBytes(text, maxBytes) {
145
+ const buf = Buffer.from(text, 'utf8');
146
+ if (buf.length <= maxBytes) return text;
147
+ let cut = Math.max(0, maxBytes);
148
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut -= 1;
149
+ return buf.subarray(0, cut).toString('utf8');
150
+ }
151
+
152
+ /**
153
+ * Validate and clean the options: each trimmed and scrubbed, THEN deduplicated (two options that
154
+ * differ only by a secret collapse into one); a given list needs at least 2 distinct options and
155
+ * at most {@link MAX_OPTIONS}. No list (or an empty one) means an open question: `[]`.
156
+ * @param {unknown} options @returns {string[]}
157
+ * @throws {SessionError} `usage`
158
+ */
159
+ export function cleanOptions(options) {
160
+ if (options === undefined || options === null) return [];
161
+ if (!Array.isArray(options)) throw new SessionError('usage', 'delegate: options must be a list of strings');
162
+ if (options.length === 0) return [];
163
+ const out = [];
164
+ for (const option of options) {
165
+ if (typeof option !== 'string' || option.trim().length === 0) throw new SessionError('usage', 'delegate: every option must be a non-empty string');
166
+ const text = scrub(option.trim());
167
+ if (Buffer.byteLength(text) > OPTION_MAX_BYTES) throw new SessionError('usage', `delegate: an option is longer than ${OPTION_MAX_BYTES} bytes`);
168
+ if (!out.includes(text)) out.push(text);
169
+ }
170
+ if (out.length < 2) throw new SessionError('usage', 'delegate: give at least 2 distinct options');
171
+ if (out.length > MAX_OPTIONS) throw new SessionError('usage', `delegate: at most ${MAX_OPTIONS} options`);
172
+ return out;
173
+ }
174
+
175
+ const SUBJECT_BLOCK = /^[A-Za-z0-9._-]{1,32}$/;
176
+ const SUBJECT_FINDING = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
177
+
178
+ /**
179
+ * Validate the ask's subject (B47): a block id, a repo-relative POSIX file (normalized; no `..`,
180
+ * no absolute path, no backslash or NUL) and a finding id, each optional.
181
+ * @param {unknown} subject
182
+ * @returns {{block?: string, file?: string, finding?: string} | null}
183
+ * @throws {SessionError} `usage`
184
+ */
185
+ export function cleanSubject(subject) {
186
+ if (subject === undefined || subject === null) return null;
187
+ if (typeof subject !== 'object' || Array.isArray(subject)) throw new SessionError('usage', 'delegate: the subject must be {block, file, finding}');
188
+ const { block, file, finding } = /** @type {Record<string, unknown>} */ (subject);
189
+ /** @type {{block?: string, file?: string, finding?: string}} */
190
+ const out = {};
191
+ if (block !== undefined) {
192
+ if (typeof block !== 'string' || !SUBJECT_BLOCK.test(block)) throw new SessionError('usage', 'delegate: --block must be a block id (letters, digits, . _ -; at most 32 characters)');
193
+ out.block = block;
194
+ }
195
+ if (file !== undefined) {
196
+ const normalized = typeof file === 'string' && file.length > 0 && file.length <= 512 && !file.includes('\\') && !file.includes('\0') && !path.posix.isAbsolute(file) ? path.posix.normalize(file) : '';
197
+ if (normalized === '' || normalized === '.' || normalized === '..' || normalized.startsWith('../')) throw new SessionError('usage', 'delegate: --file must be a repo-relative path');
198
+ out.file = normalized;
199
+ }
200
+ if (finding !== undefined) {
201
+ if (typeof finding !== 'string' || !SUBJECT_FINDING.test(finding)) throw new SessionError('usage', 'delegate: --finding must be a finding id (letters, digits, . _ : -)');
202
+ out.finding = finding;
203
+ }
204
+ return Object.keys(out).length === 0 ? null : out;
205
+ }
206
+
207
+ /**
208
+ * The delegate packet. Texts must already be scrubbed.
209
+ * @param {{scope: string, grantScopes: string[], question: string, options?: string[], context?: string, subject?: {block?: string, file?: string, finding?: string} | null}} packet
210
+ * @param {{maxTokens?: number}} [opts]
211
+ * @returns {string} at most `maxTokens` estimated tokens.
212
+ * @throws {SessionError} `usage` without a question; `delegate-too-large` when the fixed part
213
+ * (header, scopes, deny list, question, options) alone is over budget.
214
+ */
215
+ export function buildDelegatePrompt(packet, opts = {}) {
216
+ const maxTokens = opts.maxTokens ?? DELEGATE_MAX_PROMPT_TOKENS;
217
+ if (typeof packet.question !== 'string' || packet.question.trim().length === 0) throw new SessionError('usage', 'delegate: the question must be non-empty');
218
+ const what = /** @type {Record<string, string>} */ (ALLOWABLE_WHAT);
219
+ const allowed = packet.grantScopes.map((s) => `- ${s}: ${what[s] ?? s}`).join('\n');
220
+ const deny = Object.entries(FIXED_DENY).map(([s, w]) => `- ${s}: ${w}`).join('\n');
221
+ let fixed =
222
+ `${HEADER}## Scope asked\n${packet.scope}: ${what[packet.scope] ?? packet.scope}\n` +
223
+ `\n## Scopes the owner granted\n${allowed}\n` +
224
+ `\n## Never allowed (fixed deny list, enforced in code; escalate anything that touches these)\n${deny}\n` +
225
+ `\n## Question\n${packet.question.trim()}\n`;
226
+ if (packet.subject) {
227
+ const parts = [['block', packet.subject.block], ['file', packet.subject.file], ['finding', packet.subject.finding]].filter(([, v]) => v !== undefined);
228
+ fixed += `\n## Subject\n${parts.map(([k, v]) => `${k} ${v}`).join(' · ')}\n`;
229
+ }
230
+ if (Array.isArray(packet.options) && packet.options.length > 0) fixed += `\n## Options\n${packet.options.map((o) => `- ${o}`).join('\n')}\n`;
231
+ const maxBytes = maxTokens * 4;
232
+ const fixedBytes = Buffer.byteLength(fixed);
233
+ if (fixedBytes > maxBytes) throw new SessionError('delegate-too-large', `delegate: the question part is ${estimateTokens(fixedBytes)} tokens (max ${maxTokens})`);
234
+ const context = typeof packet.context === 'string' ? packet.context : '';
235
+ if (context.length === 0) return fixed;
236
+ const heading = '\n## Context\n';
237
+ const whole = `${heading}${context}\n`;
238
+ if (fixedBytes + Buffer.byteLength(whole) <= maxBytes) return fixed + whole;
239
+ const note = `\n[context cut: ${Buffer.byteLength(context)} bytes given]\n`;
240
+ const room = maxBytes - fixedBytes - Buffer.byteLength(heading) - Buffer.byteLength(note);
241
+ if (room <= 0) return fixed;
242
+ return `${fixed}${heading}${cutBytes(context, room)}${note}`;
243
+ }
244
+
245
+ /**
246
+ * `autopilot.min_confidence` of a config, or the default when unset or not a number in [0, 1].
247
+ * @param {Record<string, any> | null | undefined} cfg @returns {number}
248
+ */
249
+ export function minConfidenceOf(cfg) {
250
+ const value = cfg?.autopilot?.min_confidence;
251
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : DEFAULT_MIN_CONFIDENCE;
252
+ }
253
+
254
+ /**
255
+ * The first rule that keeps a valid answer from acting, or null when it may act.
256
+ * @param {{decision: string, within_scope: boolean, confidence: number, escalate: boolean}} answer
257
+ * @param {string[]} options @param {number} minConfidence
258
+ * @returns {'no-options' | 'outside-scope' | 'escalated' | 'low-confidence' | 'not-an-option' | null}
259
+ */
260
+ export function ownerReason(answer, options, minConfidence) {
261
+ if (options.length === 0) return 'no-options';
262
+ if (answer.within_scope !== true) return 'outside-scope';
263
+ if (answer.escalate !== false) return 'escalated';
264
+ if (!(answer.confidence >= minConfidence)) return 'low-confidence';
265
+ if (!options.includes(answer.decision)) return 'not-an-option';
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * The run's config: its snapshot in force, else `fallback`.
271
+ * @param {Record<string, any>} record @param {Record<string, any> | undefined} fallback
272
+ * @returns {Record<string, any>}
273
+ * @throws {StateError} `no-config` when neither exists; `config-snapshot` when the snapshot is damaged.
274
+ */
275
+ function runConfig(record, fallback) {
276
+ let snap;
277
+ try {
278
+ snap = snapshotFor(record, '');
279
+ } catch (err) {
280
+ // only `snapshotFor`'s own hash check is a damaged snapshot; anything else keeps its code
281
+ if (err instanceof Error && err.message === 'config-snapshot' && !(/** @type {any} */ (err).code)) {
282
+ throw new StateError('config-snapshot', `run ${record.run_id}'s config snapshot does not match its hash`);
283
+ }
284
+ throw err;
285
+ }
286
+ if (snap !== null) return snap.config;
287
+ if (fallback && typeof fallback === 'object') return fallback;
288
+ throw new StateError('no-config', `run ${record.run_id} has no config snapshot and no project config was given`);
289
+ }
290
+
291
+ /**
292
+ * Ask the delegate one question. See the module doc for the order and the rules.
293
+ * @param {DelegateAsk} ask @param {DelegateDeps} [deps]
294
+ * @returns {Promise<DelegateResult>}
295
+ * @throws {SessionError} `usage` for a malformed ask; {@link StateError} for an unknown scope,
296
+ * an unknown run, a missing config, or a decision row that could not be written.
297
+ */
298
+ export async function askDelegate(ask, deps = {}) {
299
+ const clock = deps.now ?? (() => new Date());
300
+ const { runId, scope } = ask;
301
+ if (typeof ask.question !== 'string' || ask.question.trim().length === 0) throw new SessionError('usage', 'delegate: the question must be non-empty');
302
+ if (ask.context !== undefined && typeof ask.context !== 'string') throw new SessionError('usage', 'delegate: the context must be text');
303
+ const options = cleanOptions(ask.options); // scrubbed, then deduplicated
304
+ const subject = cleanSubject(ask.subject);
305
+
306
+ const gate = await grantFor(runId, scope, clock(), { writeRow: deps.writeRow, checks: deps.checks });
307
+ if (!gate.ok) return { answered: false, acted: false, to_owner: true, reason: gate.reason, grant_id: gate.grant?.grant_id ?? null, answer: null, row: null };
308
+ const grant = gate.grant;
309
+
310
+ const record = await readRun(runId);
311
+ const cfg = runConfig(record, ask.cfg);
312
+ const slug = record.project;
313
+ const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
314
+
315
+ const question = scrub(ask.question.trim());
316
+ const context = typeof ask.context === 'string' ? scrub(ask.context) : '';
317
+
318
+ /** @type {Record<string, any> | null} */
319
+ let answer = null;
320
+ /** @type {string | null} */
321
+ let failure = null;
322
+ /** @type {{provider: string | null, model: string | null}} */
323
+ let ran = { provider: null, model: null };
324
+ /** @type {string | null} */
325
+ let dir = null;
326
+ try {
327
+ const prompt = buildDelegatePrompt({ scope, grantScopes: grant.scopes, question, options, context, subject });
328
+ const root = runRootFor(runId, cfg?.tmp?.root);
329
+ dir = path.join(root, 'delegate', `${Date.now()}-${randomBytes(4).toString('hex')}`);
330
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
331
+ const packetPath = path.join(dir, 'packet.md');
332
+ writeFileSync(packetPath, prompt, { mode: 0o600 });
333
+ const result = await spawnSession(
334
+ {
335
+ cfg,
336
+ level: /** @type {'L2' | 'L3'} */ (grant.delegate),
337
+ role: 'delegate',
338
+ promptPath: packetPath,
339
+ schema: DELEGATE_SCHEMA,
340
+ systemPromptText: DELEGATE_LENS,
341
+ runRoot: root,
342
+ run: runId,
343
+ slug,
344
+ timeoutMs: ask.timeoutMs,
345
+ rowExtra: { grant_id: grant.grant_id },
346
+ },
347
+ deps, // the session row goes to `deps.writeRow` too, else to the run's ledger (`slug`)
348
+ );
349
+ ran = { provider: result.provider ?? null, model: result.model ?? null };
350
+ if (result.status !== 'ok') failure = `session ${result.status}${result.reason ? ` (${result.reason})` : ''}`;
351
+ // the OpenAI strict variant lets every field be null; the answer must still meet the source schema
352
+ else if (!matchesSchema(DELEGATE_SCHEMA, result.answer)) failure = 'session invalid-output (answer does not match the delegate schema)';
353
+ else if (result.answer.reason.length > DELEGATE_REASON_MAX) failure = `session invalid-output (reason is longer than ${DELEGATE_REASON_MAX} characters)`;
354
+ else answer = result.answer;
355
+ } catch (err) {
356
+ // a spawner refusal keeps its code and message; anything else (the packet dir or file could
357
+ // not be written, a CLI that could not start) is recorded without its text, which may carry paths
358
+ failure = err instanceof SessionError ? `${err.code}: ${err.message}` : 'internal-error';
359
+ } finally {
360
+ try {
361
+ if (dir !== null) rmSync(dir, { recursive: true, force: true });
362
+ } catch {
363
+ // the run root sweep removes it later; the decision row must still be written
364
+ }
365
+ }
366
+
367
+ /** @type {string | null} */
368
+ let toOwnerWhy = failure === null ? ownerReason(/** @type {any} */ (answer), options, minConfidenceOf(cfg)) : 'session-failed';
369
+ if (toOwnerWhy === null) {
370
+ // the session may have outlived the window or a stop: the grant is asked again before acting
371
+ try {
372
+ const again = await grantFor(runId, scope, clock(), { writeRow: deps.writeRow, checks: deps.checks });
373
+ if (!again.ok) toOwnerWhy = `grant-${again.reason}`;
374
+ } catch {
375
+ failure = 'internal-error'; // the grant could not be re-read: fail closed, still one row
376
+ toOwnerWhy = 'internal-error';
377
+ }
378
+ }
379
+ const acted = toOwnerWhy === null;
380
+ const row = {
381
+ event: 'autopilot.decision',
382
+ decision_id: `ad-${randomBytes(6).toString('hex')}`,
383
+ grant_id: grant.grant_id,
384
+ scope,
385
+ subject,
386
+ question: cutBytes(question, DECISION_QUESTION_MAX_BYTES),
387
+ options,
388
+ decision: answer === null ? null : scrub(answer.decision),
389
+ within_scope: answer === null ? null : answer.within_scope,
390
+ confidence: answer === null ? null : answer.confidence,
391
+ reason: answer === null ? null : scrub(answer.reason),
392
+ escalate: answer === null ? null : answer.escalate,
393
+ acted,
394
+ to_owner: !acted,
395
+ owner_reason: toOwnerWhy,
396
+ failure: failure === null ? null : scrub(failure),
397
+ level: grant.delegate,
398
+ provider: ran.provider,
399
+ model: ran.model,
400
+ ts: clock().toISOString(),
401
+ };
402
+ const signed = /** @type {Record<string, any>} */ (await writeSigned(runId, async (r) => {
403
+ await writeRow(r);
404
+ return r;
405
+ }, row));
406
+ // B49a: the owner's files follow the decision; best effort, never a reason to fail it
407
+ try {
408
+ if (deps.afterDecision) await deps.afterDecision(runId);
409
+ else if (!deps.writeRow) await refreshAutopilotFiles(runId, { now: clock() });
410
+ } catch {
411
+ // the decision row stands; the files are rewritten by the next decision or `autopilot binnacle`
412
+ }
413
+ return {
414
+ answered: answer !== null,
415
+ acted,
416
+ to_owner: !acted,
417
+ reason: failure ?? toOwnerWhy,
418
+ grant_id: grant.grant_id,
419
+ answer: answer === null ? null : { ...answer, decision: row.decision, reason: row.reason },
420
+ row: signed,
421
+ };
422
+ }