@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.
- package/CHANGELOG.md +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- 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
|
+
}
|