@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,95 @@
1
+ /**
2
+ * The autopilot scope vocabulary (issue #5, plan autopilot §1): the ONE place that says what a
3
+ * grant may allow and what it never may. Every other autopilot module reads these lists; none
4
+ * keeps its own copy.
5
+ *
6
+ * - {@link ALLOWABLE_SCOPES}: what `autopilot start --allow` accepts.
7
+ * - {@link FIXED_DENY}: always denied. A grant that names one of these in `--allow` is refused at
8
+ * `start` with the scope named; `grantFor` answers `denied` for them whatever the grant says.
9
+ * The list is code, not prompt text: the delegate cannot argue its way past it.
10
+ * - {@link LIMIT_KEYS}: the config key paths behind `limits:change` (a change to any of them, or
11
+ * to a key under one, relaxes a rule). B48 checks `run reload` against it.
12
+ * - {@link BUDGET_CATEGORIES}: the categories `--budget <category>=<usd>` accepts (B48 meters them).
13
+ */
14
+
15
+ /** Scopes an owner may delegate. */
16
+ export const ALLOWABLE_SCOPES = Object.freeze(['waive:warning', 'waive:nit', 'round:extra', 'model:choose']);
17
+
18
+ /** What each allowable scope lets the delegate do (status output and docs). */
19
+ export const ALLOWABLE_WHAT = Object.freeze({
20
+ 'waive:warning': 'waive a warning finding',
21
+ 'waive:nit': 'waive a nit finding',
22
+ 'round:extra': 'one fix round past the cap for a file',
23
+ 'model:choose': 'choose the coder level for a block',
24
+ });
25
+
26
+ /**
27
+ * Config key paths behind `limits:change`. A key path is covered when it equals one of these,
28
+ * sits under one (`budget.usd` under `budget`), or is a parent of one (`review`, and the root
29
+ * `''`): replacing a parent replaces the limit inside it.
30
+ */
31
+ export const LIMIT_KEYS = Object.freeze([
32
+ 'budget',
33
+ 'review.budgets',
34
+ 'review.block_budget_tokens',
35
+ 'review.block_budget_usd',
36
+ 'review.max_rounds_per_file',
37
+ 'review.single_reviewer_max_risk',
38
+ 'review.multimodel',
39
+ 'review.closed_book',
40
+ 'review.allow_open_book_codex',
41
+ 'thresholds',
42
+ 'escalation',
43
+ 'proof',
44
+ 'system1',
45
+ 'production',
46
+ // B46: lowering `autopilot.min_confidence` lets the delegate act on weaker answers
47
+ 'autopilot',
48
+ ]);
49
+
50
+ /** @param {string} keyPath @returns {boolean} whether changing `keyPath` is a `limits:change`. */
51
+ export function isLimitKey(keyPath) {
52
+ if (keyPath === '') return true;
53
+ return LIMIT_KEYS.some((k) => keyPath === k || keyPath.startsWith(`${k}.`) || k.startsWith(`${keyPath}.`));
54
+ }
55
+
56
+ /** {@link LIMIT_KEYS} as text: a top-level group as `name.*`, a nested key as itself. */
57
+ export const LIMIT_KEYS_TEXT = LIMIT_KEYS.map((k) => (k.includes('.') ? k : `${k}.*`)).join(', ');
58
+
59
+ /** The fixed deny list: scope → what it would have allowed. Never delegable. */
60
+ export const FIXED_DENY = Object.freeze({
61
+ 'waive:critical': 'waive a critical finding',
62
+ 'waive:proof': 'waive a proof finding',
63
+ 'reviews:skip': 'close a block without its reviews (--no-require-reviews)',
64
+ 'limits:change': `change a limit or rule key (${LIMIT_KEYS_TEXT})`,
65
+ 'plan:approve': 'approve a plan',
66
+ 'design:approve': 'approve a design',
67
+ 'pr:merge': 'merge a pull request',
68
+ destructive: 'run a destructive action',
69
+ 'budget:raise': 'raise a budget',
70
+ });
71
+
72
+ /** @type {ReadonlyArray<string>} */
73
+ export const FIXED_DENY_SCOPES = Object.freeze(Object.keys(FIXED_DENY));
74
+
75
+ /** Every scope name the vocabulary knows. */
76
+ export const ALL_SCOPES = Object.freeze([...ALLOWABLE_SCOPES, ...FIXED_DENY_SCOPES]);
77
+
78
+ /** Budget categories `--budget` accepts. */
79
+ export const BUDGET_CATEGORIES = Object.freeze(['coding', 'review']);
80
+
81
+ /**
82
+ * B48: the budget category each allowable scope spends from. A waiver ends a review question
83
+ * (review); an extra fix round and the coder level start coder sessions (coding). While a
84
+ * category is paused, `grantFor` answers `paused` for its scopes.
85
+ */
86
+ export const SCOPE_CATEGORY = Object.freeze({ 'waive:warning': 'review', 'waive:nit': 'review', 'round:extra': 'coding', 'model:choose': 'coding' });
87
+
88
+ /** The delegate levels `--delegate` accepts. */
89
+ export const DELEGATE_LEVELS = Object.freeze(['L2', 'L3']);
90
+
91
+ /** @param {string} scope @returns {boolean} */
92
+ export const isAllowable = (scope) => ALLOWABLE_SCOPES.includes(scope);
93
+
94
+ /** @param {string} scope @returns {boolean} */
95
+ export const isFixedDeny = (scope) => Object.hasOwn(FIXED_DENY, scope);
@@ -11,13 +11,12 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
- import { loadProjectConfig } from '../config/load.mjs';
14
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
15
15
  import { AuthorError, runAuthor } from '../session/author.mjs';
16
16
  import { runRootFor, SessionError } from '../session/spawn.mjs';
17
17
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
18
18
  import { StateError } from '../state/paths.mjs';
19
19
  import { writeSafe } from '../util/redact.mjs';
20
- import { slugFor } from './run.mjs';
21
20
  import { exitCodeFor } from './spawn.mjs';
22
21
 
23
22
  const USAGE = 'usage: code-forge author --job plan|harden --brief <file> [--facts <file>] [--draft <file>] [--answers <file>] [--out <file>] [--run <id>] [--timeout <seconds>]\n';
@@ -40,7 +39,10 @@ export async function runAuthorVerb(args, deps = {}) {
40
39
  /** @param {string | string[] | true | undefined} v */
41
40
  const abs = (v) => (typeof v === 'string' ? path.resolve(cwd, v) : undefined);
42
41
  const briefPath = /** @type {string} */ (abs(flags.brief));
43
- const loaded = await loadProjectConfig(cwd);
42
+ // B50: the project root's config and ledger slug, also from a subfolder; flag paths stay
43
+ // cwd-relative, and the default output goes to <root>/<plans_dir>
44
+ const root = projectRootFor(cwd);
45
+ const loaded = await loadProjectConfig(root);
44
46
  if (!loaded.ok) {
45
47
  writeSafe(stderr, `author: ${loaded.message}\n`);
46
48
  return 2;
@@ -58,7 +60,7 @@ export async function runAuthorVerb(args, deps = {}) {
58
60
  answersPath: abs(flags.answers),
59
61
  run,
60
62
  runRoot: run ? runRootFor(run, cfg?.tmp?.root) : undefined,
61
- slug: slugFor(cfg, cwd),
63
+ slug: slugFor(cfg, root),
62
64
  timeoutMs: timeout === undefined ? undefined : timeout * 1000,
63
65
  },
64
66
  { ...deps, stderr },
@@ -67,7 +69,7 @@ export async function runAuthorVerb(args, deps = {}) {
67
69
  writeSafe(stderr, `author: ${result.status}${result.reason ? ` (${result.reason})` : ''}\n`);
68
70
  return exitCodeFor(result);
69
71
  }
70
- const out = abs(flags.out) ?? path.join(path.resolve(cwd, cfg.project?.plans_dir ?? 'plans'), `${path.basename(briefPath, path.extname(briefPath))}.${flags.job}.md`);
72
+ const out = abs(flags.out) ?? path.join(path.resolve(root, cfg.project?.plans_dir ?? 'plans'), `${path.basename(briefPath, path.extname(briefPath))}.${flags.job}.md`);
71
73
  mkdirSync(path.dirname(out), { recursive: true });
72
74
  writeFileSync(out, /** @type {string} */ (result.draft));
73
75
  writeSafe(stdout, `${JSON.stringify({ draft: out, questions: result.questions, cost: result.cost })}\n`);
@@ -0,0 +1,500 @@
1
+ /**
2
+ * `code-forge autopilot start|status|stop|ask|waive|round|level|approve` (issue #5, blocks B45–B48). The OWNER (and the
3
+ * orchestrator on the owner's behalf) runs these — a coder never may: `autopilot` is on the
4
+ * coder-only forbidden list.
5
+ *
6
+ * autopilot start --run <id> --until <ISO-8601 with offset> --delegate <L2|L3> --allow <scope,…>
7
+ * [--deny <scope,…>] [--budget <category>=<usd>,…] [--stop-at <0..1>] [--yes]
8
+ * autopilot status --run <id> [--json]
9
+ * autopilot stop --run <id>
10
+ * (B49b: both print the live doc's link stored in the grant, or — without one — rewrite and print the two
11
+ * Markdown files `autopilot-binnacle.md` and `autopilot-log.md`, which are then the record)
12
+ * autopilot ask --run <id> --scope <scope> --question <text> [--options a,b,…] [--context-file <path>]
13
+ * [--block <id>] [--file <path>] [--finding <id>] [--json]
14
+ * autopilot waive --run <id> --block <id> --file <path> --finding <id> --severity <warning|nit> --reason <text> --decision <id>
15
+ * autopilot round --run <id> --block <id> --file <path> --decision <id>
16
+ * autopilot level --run <id> --block <id> --plan <file> --decision <id>
17
+ * autopilot approve --run <id> --key <dot.path> --value <json> --until <ISO-8601 with offset>
18
+ * autopilot binnacle --run <id> [--json|--markdown] [--link <https url>]
19
+ * autopilot log --run <id> [--json|--markdown]
20
+ *
21
+ * B49a (`src/autopilot/binnacle.mjs`): `binnacle` is the owner's summary of the run (status, decisions, blocks,
22
+ * open questions, owner actions, incidents, timeline) and `log` lists every autopilot ledger row, newest first;
23
+ * Markdown to stdout by default, `--json` prints data, `--markdown` writes the files in the run dir (0600) and
24
+ * prints the path. `--link` stores the https link of the live doc in the active grant (signed `autopilot.link`).
25
+ *
26
+ * B48 (`src/autopilot/limits.mjs`): `approve` is the OWNER's temporary config change: it writes
27
+ * the workspace `.code-forge.yml`, reloads the run the way `run reload` does, and the old value
28
+ * comes back at `--until` (lazily: at the next autopilot or block command or worker drain), with a
29
+ * signed `autopilot.approve` and `autopilot.restore` row. It runs only at a terminal, after the
30
+ * owner confirms; there is deliberately NO `--yes`: a flag can be passed by any process — the
31
+ * orchestrator, a script, the delegate's own tooling — and `approve` is the one way to relax a
32
+ * limit while autopilot runs, so an unattended caller must never be able to approve. Fixed keys
33
+ * (`IMMUTABLE_KEYS`) are refused. Every subcommand first restores expired approvals. `status`
34
+ * shows the spend per budget category since the grant started and the paused categories.
35
+ * B47 (`src/autopilot/actions.mjs`): `waive`, `round` and `level` are delegated actions — each is
36
+ * refused (exit 1, nothing written) unless the grant allows its scope (`waive:<severity>`,
37
+ * `round:extra`, `model:choose`) AND `--decision` names the delegate's acted decision for it (an
38
+ * `autopilot ask` with `--block` [`--file` `--finding`] under the same grant and scope, at most
39
+ * 30 min old) whose answer is the action's fixed option word: ask a waive with `--options
40
+ * waive,fix` (it needs `waive`), a round with `--options allow,deny` (needs `allow`), a level with
41
+ * the candidate levels, e.g. `--options L1,L2` (needs the level `autopilot level` sets). Any other
42
+ * answer, even acted, authorises nothing. `waive` reads the finding's severity from the signed review
43
+ * rows (critical or unknown ⇒ refused; `proof` never) and opens or comments on one tracked issue
44
+ * in the project's repo; without `gh` the waiver still stands (`issue: none`). `level` prints the
45
+ * chosen coder level; `block open` without `--level` uses it.
46
+ * `ask` (B46) puts one question to the delegate (`src/autopilot/delegate.mjs`): exit 0 when the
47
+ * answer is acted on, 3 when the question goes to the owner (answered but not acted, or the
48
+ * session failed — one signed `autopilot.decision` row either way), 1 when the grant refuses
49
+ * (no grant, expired, stopped, denied, not allowed: no session, no row), 2 for usage.
50
+ * `start` grants a delegate the listed scopes until `--until` (at most 24 h ahead). It asks for
51
+ * confirmation on a terminal; without one it is refused unless `--yes` is given. Scopes outside
52
+ * the vocabulary, or on the fixed deny list, are refused by name (`src/autopilot/scopes.mjs`).
53
+ * Every subcommand runs the lazy expiry check first, so an expired grant gets its one
54
+ * `autopilot.expire` row here at the latest.
55
+ */
56
+
57
+ import { activeGrantMessage, checkExpiry, expiredMessage, grantState, startGrant, stopGrant, validateGrantInput } from '../autopilot/grant.mjs';
58
+ import { askDelegate } from '../autopilot/delegate.mjs';
59
+ import { buildBinnacle, gitOwner, scrubCtxOf, buildFullLog, refreshAutopilotFiles, renderBinnacleMarkdown, renderLogMarkdown, storeLink, writeAutopilotFiles } from '../autopilot/binnacle.mjs';
60
+ import { chooseCoderLevel, grantExtraRound, waiveForOwner } from '../autopilot/actions.mjs';
61
+ import { approvalCheckText, approveChange, checkApprovals, spendByCategory, validateApproval } from '../autopilot/limits.mjs';
62
+ import { BUDGET_CATEGORIES, FIXED_DENY_SCOPES } from '../autopilot/scopes.mjs';
63
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
64
+ import { redact } from '../util/redact.mjs';
65
+ import { readFile } from 'node:fs/promises';
66
+ import path from 'node:path';
67
+ import { loadProjectConfig } from '../config/load.mjs';
68
+ import { SessionError } from '../session/spawn.mjs';
69
+ import { parseFlags } from '../state/cli-args.mjs';
70
+ import { StateError } from '../state/paths.mjs';
71
+ import { readRun } from '../state/run.mjs';
72
+ import { writeSafe } from '../util/redact.mjs';
73
+
74
+ /**
75
+ * @typedef {{
76
+ * stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown},
77
+ * now?: () => Date, isTTY?: boolean, ui?: any,
78
+ * writeRow?: (row: Record<string, any>) => Promise<unknown>,
79
+ * cwd?: string, session?: import('../session/spawn.mjs').SessionDeps,
80
+ * actions?: import('../autopilot/actions.mjs').ActionDeps,
81
+ * }} AutopilotDeps - `cwd`: where `ask` loads the project config when the run has no snapshot;
82
+ * `session`: the spawner seams (`bins`, `env`, `exec`, …) for `ask` (tests: the fake CLIs);
83
+ * `actions`: the B47 seams (`exec`, `env`, `onPath` for gh; `readRows`).
84
+ */
85
+
86
+ const SUBCOMMANDS = Object.freeze(['start', 'status', 'stop', 'ask', 'waive', 'round', 'level', 'approve', 'binnacle', 'log']);
87
+
88
+ const FLAG_SPECS = Object.freeze({
89
+ start: { values: ['run', 'until', 'delegate', 'allow', 'deny', 'budget', 'stop-at'], booleans: ['yes'] },
90
+ status: { values: ['run'], booleans: ['json'] },
91
+ stop: { values: ['run'] },
92
+ ask: { values: ['run', 'scope', 'question', 'options', 'context-file', 'block', 'file', 'finding'], booleans: ['json'] },
93
+ waive: { values: ['run', 'block', 'file', 'finding', 'severity', 'reason', 'decision'] },
94
+ round: { values: ['run', 'block', 'file', 'decision'] },
95
+ level: { values: ['run', 'block', 'plan', 'decision'] },
96
+ approve: { values: ['run', 'key', 'value', 'until'] },
97
+ binnacle: { values: ['run', 'link'], booleans: ['json', 'markdown'] },
98
+ log: { values: ['run'], booleans: ['json', 'markdown'] },
99
+ });
100
+
101
+ /** Why `approve` takes no `--yes` (the refusal text). */
102
+ export const APPROVE_NO_YES =
103
+ 'autopilot approve: --yes is not accepted — only the owner approves a limit change, at a terminal; a flag could be passed by the orchestrator or a script, so approve always asks';
104
+
105
+ /** The refusal without a terminal. */
106
+ export const APPROVE_NO_TTY = 'autopilot approve: no terminal to confirm; only the owner approves a change, in a terminal (approve has no --yes)';
107
+
108
+ /** @param {object} res - a refused action result. @returns {string} its message. */
109
+ const refusal = (res) => /** @type {{message: string}} */ (res).message;
110
+
111
+ /**
112
+ * `autopilot waive|round|level` (B47): one delegated action.
113
+ * @param {'waive' | 'round' | 'level'} sub @param {string} runId @param {Record<string, any>} flags
114
+ * @param {AutopilotDeps} deps @param {(s: string) => void} out @param {(s: string) => void} err
115
+ * @returns {Promise<number>} 0 done, 1 refused
116
+ */
117
+ async function runAction(sub, runId, flags, deps, out, err) {
118
+ /** @param {string} name */
119
+ const need = (name) => {
120
+ if (typeof flags[name] !== 'string' || flags[name].trim().length === 0) throw new StateError('usage', `autopilot ${sub} needs --${name}`);
121
+ return /** @type {string} */ (flags[name]);
122
+ };
123
+ const actionDeps = { ...(deps.actions ?? {}), writeRow: deps.writeRow, ...(deps.now ? { now: deps.now } : {}) };
124
+ if (sub === 'waive') {
125
+ const req = { runId, block: need('block'), file: need('file'), finding: need('finding'), severity: need('severity'), reason: need('reason'), decisionId: need('decision') };
126
+ const res = await waiveForOwner(req, actionDeps);
127
+ if (!res.ok) {
128
+ err(`autopilot waive: refused — ${refusal(res)}\n`);
129
+ return 1;
130
+ }
131
+ const issue =
132
+ res.issue !== null
133
+ ? `issue ${res.issue} (${res.issueAction})`
134
+ : `issue none (${res.issueFailure}; the waiver still stands${res.issueRowWritten ? '' : '; the autopilot.issue_failed row could not be written'})`;
135
+ out(`autopilot waive run ${runId}: waived ${req.finding} (${res.row.severity}) in ${res.row.file}, block ${req.block} (by: autopilot, grant ${res.row.grant_id}) · ${issue}\n`);
136
+ return 0;
137
+ }
138
+ if (sub === 'round') {
139
+ const res = await grantExtraRound({ runId, block: need('block'), file: need('file'), decisionId: need('decision') }, actionDeps);
140
+ if (!res.ok) {
141
+ err(`autopilot round: refused — ${refusal(res)}\n`);
142
+ return 1;
143
+ }
144
+ out(`autopilot round run ${runId}: one extra fix round for ${res.row.file} in block ${res.row.block} (grant ${res.row.grant_id})\n`);
145
+ return 0;
146
+ }
147
+ const plan = path.resolve(deps.cwd ?? process.cwd(), need('plan'));
148
+ const res = await chooseCoderLevel({ runId, block: need('block'), plan, decisionId: need('decision') }, actionDeps);
149
+ if (!res.ok) {
150
+ err(`autopilot level: refused — ${refusal(res)}\n`);
151
+ return 1;
152
+ }
153
+ out(`autopilot level run ${runId}: block ${res.row.block} codes at ${res.row.level} (lane ${res.row.lane}, plan ${res.row.plan_level}, grant ${res.row.grant_id}); block open without --level uses it\n`);
154
+ return 0;
155
+ }
156
+
157
+ /**
158
+ * `autopilot approve` (B48): validate, ask the owner at a terminal (never without one, never
159
+ * `--yes`), then apply the change through the `run reload` path.
160
+ * @param {string} runId @param {Record<string, any>} flags @param {AutopilotDeps} deps
161
+ * @param {(s: string) => void} out @param {(s: string) => void} err
162
+ * @returns {Promise<number>} 0 approved, 1 refused or cancelled, 2 usage or no terminal
163
+ */
164
+ async function runApprove(runId, flags, deps, out, err) {
165
+ const clock = deps.now ?? (() => new Date());
166
+ const valid = await validateApproval({ runId, key: flags.key, value: flags.value, until: flags.until, now: clock() });
167
+ if (!(deps.isTTY ?? process.stdin.isTTY === true)) {
168
+ err(`${APPROVE_NO_TTY}\n`);
169
+ return 2;
170
+ }
171
+ const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
172
+ const go = await ui.confirm({
173
+ message: `Approve ${valid.key} = ${JSON.stringify(redact(valid.value))} for run ${runId} until ${valid.until}? The old value comes back then.`,
174
+ initialValue: false,
175
+ });
176
+ if (ui.isCancel(go) || go !== true) {
177
+ out('cancelled — nothing changed\n');
178
+ return 1;
179
+ }
180
+ // the prompt may have waited: --until is checked again against now
181
+ const now = clock();
182
+ const again = await validateApproval({ runId, key: flags.key, value: flags.value, until: flags.until, now });
183
+ const approval = await approveChange({ runId, key: again.key, segs: again.segs, value: again.value, until: again.until, now, writeRow: deps.writeRow });
184
+ out(`autopilot approve run ${runId}: ${approval.key} changed until ${approval.until} (approval ${approval.approval_id}); the old value comes back then\n`);
185
+ return 0;
186
+ }
187
+
188
+ /**
189
+ * `autopilot binnacle` / `autopilot log` (B49a): the owner's summary and the full log of a run.
190
+ * Default Markdown to stdout; `--json` prints the data; `--markdown` writes `autopilot-binnacle.md`
191
+ * and `autopilot-log.md` in the run dir (both, so they never disagree) and prints the path.
192
+ * `--link <url>` (binnacle only) first stores the https link in the active grant.
193
+ * @param {'binnacle' | 'log'} sub @param {string} runId @param {Record<string, any>} flags
194
+ * @param {AutopilotDeps} deps @param {(s: string) => void} out
195
+ * @param {import('../state/run.mjs').WriteRow} writer
196
+ * @returns {Promise<number>}
197
+ */
198
+ async function runBinnacle(sub, runId, flags, deps, out, writer) {
199
+ if (flags.json && flags.markdown) throw new StateError('usage', 'give --json or --markdown, not both');
200
+ const clock = deps.now ?? (() => new Date());
201
+ if (sub === 'binnacle' && flags.link !== undefined) {
202
+ // the window is checked first: an expired grant gets its one expire row and no link row
203
+ const checked = await checkExpiry(runId, clock(), { writeRow: writer });
204
+ if (checked.state === 'expired') throw new StateError('grant-expired', expiredMessage(runId, checked.grant.until));
205
+ await storeLink({ runId, link: flags.link, now: clock(), writeRow: writer });
206
+ }
207
+ const now = clock();
208
+ await checkExpiry(runId, now, { writeRow: writer }); // an expired window gets its one row before the read
209
+ const record = await readRun(runId);
210
+ const rows = await readAllRows(record.project);
211
+ const owner = await gitOwner(record.workspace);
212
+ if (flags.json) {
213
+ const data = sub === 'binnacle' ? buildBinnacle({ runId, rows, record, now, owner }) : buildFullLog({ runId, rows, scrubCtx: scrubCtxOf(record) });
214
+ out(`${JSON.stringify(data, null, 2)}\n`);
215
+ return 0;
216
+ }
217
+ if (flags.markdown) {
218
+ const files = writeAutopilotFiles({ runId, rows, record, now, owner });
219
+ out(`${sub === 'binnacle' ? files.binnacle : files.log}\n`);
220
+ return 0;
221
+ }
222
+ out(sub === 'binnacle' ? renderBinnacleMarkdown(buildBinnacle({ runId, rows, record, now, owner }), scrubCtxOf(record)) : renderLogMarkdown({ runId, entries: buildFullLog({ runId, rows, scrubCtx: scrubCtxOf(record) }) }));
223
+ return 0;
224
+ }
225
+
226
+ /** `--options a,b,…` → the trimmed, non-empty items. @param {unknown} text @returns {string[] | undefined} */
227
+ const optionList = (text) => (typeof text === 'string' ? text.split(',').map((s) => s.trim()).filter((s) => s.length > 0) : undefined);
228
+
229
+ /**
230
+ * `autopilot ask`: one delegate question.
231
+ * @param {string} runId @param {Record<string, any>} flags @param {AutopilotDeps} deps
232
+ * @param {(s: string) => void} out
233
+ * @returns {Promise<number>} 0 acted, 3 to the owner, 1 refused
234
+ */
235
+ async function runAsk(runId, flags, deps, out) {
236
+ if (typeof flags.scope !== 'string' || flags.scope.trim().length === 0) throw new StateError('usage', 'autopilot ask needs --scope <scope>');
237
+ if (typeof flags.question !== 'string' || flags.question.trim().length === 0) throw new StateError('usage', 'autopilot ask needs --question <text>');
238
+ const options = optionList(flags.options);
239
+ // `--options ","` parses to nothing: refused here, never sent on as an open (no-options) question
240
+ if (options !== undefined && options.length === 0) throw new StateError('usage', '--options needs at least 2 distinct options, e.g. --options waive,fix');
241
+ /** @type {string | undefined} */
242
+ let context;
243
+ if (typeof flags['context-file'] === 'string') {
244
+ try {
245
+ context = await readFile(path.resolve(deps.cwd ?? process.cwd(), flags['context-file']), 'utf8');
246
+ } catch {
247
+ throw new StateError('usage', `--context-file ${flags['context-file']} cannot be read`); // the path only, never content
248
+ }
249
+ }
250
+ /** @type {Record<string, any> | undefined} */
251
+ let cfg;
252
+ try {
253
+ const loaded = await loadProjectConfig(deps.cwd ?? process.cwd());
254
+ cfg = loaded.ok ? loaded.config : undefined; // only used when the run has no config snapshot
255
+ } catch {
256
+ cfg = undefined;
257
+ }
258
+ /** @type {Record<string, string>} */
259
+ const subject = {};
260
+ for (const name of ['block', 'file', 'finding']) if (typeof flags[name] === 'string') subject[name] = flags[name];
261
+ const res = await askDelegate(
262
+ { runId, scope: flags.scope.trim(), question: flags.question, options, context, cfg, ...(Object.keys(subject).length > 0 ? { subject } : {}) },
263
+ { ...(deps.session ?? {}), stderr: deps.stderr ?? deps.session?.stderr, writeRow: deps.writeRow, ...(deps.now ? { now: deps.now } : {}) },
264
+ );
265
+ const code = res.acted ? 0 : res.row === null ? 1 : 3;
266
+ const answer = res.answer;
267
+ if (flags.json) {
268
+ out(
269
+ `${JSON.stringify({
270
+ run: runId,
271
+ scope: flags.scope,
272
+ grant_id: res.grant_id,
273
+ decision_id: res.row?.decision_id ?? null,
274
+ answered: res.answered,
275
+ acted: res.acted,
276
+ to_owner: res.to_owner,
277
+ reason: res.reason,
278
+ decision: answer?.decision ?? null,
279
+ confidence: answer?.confidence ?? null,
280
+ within_scope: answer?.within_scope ?? null,
281
+ escalate: answer?.escalate ?? null,
282
+ answer_reason: answer?.reason ?? null,
283
+ })}\n`,
284
+ );
285
+ return code;
286
+ }
287
+ if (code === 1) {
288
+ out(`autopilot ask run ${runId}: refused (${res.reason}) — no session; the question goes to the owner\n`);
289
+ return code;
290
+ }
291
+ const head = res.acted ? `acted · decision ${res.row?.decision_id}` : `to the owner (${res.reason})`;
292
+ out(`autopilot ask run ${runId}: ${head}\n`);
293
+ if (answer) out(` decision ${answer.decision} · confidence ${answer.confidence} · within scope ${answer.within_scope} · escalate ${answer.escalate}\n reason ${answer.reason}\n`);
294
+ return code;
295
+ }
296
+
297
+ /**
298
+ * Time left as `<h>h <m>m` (whole minutes, rounded down).
299
+ * @param {number} ms @returns {string}
300
+ */
301
+ export function formatLeft(ms) {
302
+ const minutes = Math.max(0, Math.floor(ms / 60000));
303
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
304
+ }
305
+
306
+ /** @param {Record<string, number>} caps @returns {string} */
307
+ const capsText = (caps) => {
308
+ const keys = Object.keys(caps).sort();
309
+ return keys.length === 0 ? 'no caps' : keys.map((k) => `${k} ${caps[k].toFixed(2)} USD`).join(', ');
310
+ };
311
+
312
+ /**
313
+ * B48: spend per category against its cap, e.g. `coding 4.50 of 20.00 USD, review 0.00 USD (no cap)`.
314
+ * @param {Record<string, number>} spend @param {Record<string, number>} caps @returns {string}
315
+ */
316
+ const spendText = (spend, caps) =>
317
+ BUDGET_CATEGORIES.map((c) => {
318
+ const spent = (spend[c] ?? 0).toFixed(2);
319
+ return typeof caps[c] === 'number' ? `${c} ${spent} of ${caps[c].toFixed(2)} USD` : `${c} ${spent} USD (no cap)`;
320
+ }).join(', ');
321
+
322
+ /**
323
+ * @param {string} runId @param {import('../autopilot/grant.mjs').Grant | null} grant @param {Date} now
324
+ * @param {string} [checked] - the state `checkExpiry` answered (`run-ended` overrides the grant's own)
325
+ * @param {Record<string, number>} [spend] - B48: USD per budget category since the grant started.
326
+ * @param {{binnacle: string, log: string} | null} [files] - B49b: the two Markdown files, when the grant has no link.
327
+ * @returns {Record<string, any>}
328
+ */
329
+ function statusData(runId, grant, now, checked, spend = {}, files = null) {
330
+ if (grant === null) {
331
+ return { run: runId, state: 'none', grant_id: null, delegate: null, until: null, time_left_s: null, scopes: [], deny: [], fixed_deny: [...FIXED_DENY_SCOPES], caps: {}, stop_at: null, spend: {}, paused: [], link: null, files: null, started_at: null, stopped_at: null, expired_at: null };
332
+ }
333
+ const state = checked === 'run-ended' ? 'run-ended' : grantState(grant, now);
334
+ const left = state === 'active' ? Math.floor((Date.parse(grant.until) - now.getTime()) / 1000) : 0;
335
+ return {
336
+ run: runId,
337
+ state,
338
+ grant_id: grant.grant_id,
339
+ delegate: grant.delegate,
340
+ until: grant.until,
341
+ time_left_s: left,
342
+ scopes: grant.scopes,
343
+ deny: grant.deny,
344
+ fixed_deny: [...FIXED_DENY_SCOPES],
345
+ caps: grant.caps,
346
+ stop_at: grant.stop_at,
347
+ spend,
348
+ paused: Object.keys(grant.paused ?? {}).sort(),
349
+ link: grant.link ?? null,
350
+ files: grant.link ? null : files,
351
+ started_at: grant.started_at,
352
+ stopped_at: grant.stopped_at,
353
+ expired_at: grant.expired_at,
354
+ };
355
+ }
356
+
357
+ /** @param {Record<string, any>} d @returns {string} */
358
+ function statusText(d) {
359
+ if (d.state === 'none') return `autopilot run ${d.run}: no grant\n`;
360
+ const head =
361
+ d.state === 'active'
362
+ ? `active · ${formatLeft(d.time_left_s * 1000)} left (until ${d.until})`
363
+ : d.state === 'stopped'
364
+ ? `stopped at ${d.stopped_at} (was until ${d.until})`
365
+ : d.state === 'run-ended'
366
+ ? `the run has ended; the grant no longer acts (was until ${d.until})`
367
+ : `expired at ${d.until}`;
368
+ return (
369
+ `autopilot run ${d.run}: ${head}\n` +
370
+ ` grant ${d.grant_id}\n` +
371
+ ` delegate ${d.delegate}\n` +
372
+ ` allow ${d.scopes.join(', ')}\n` +
373
+ ` deny ${[...d.deny, ...d.fixed_deny].join(', ')}\n` +
374
+ ` budget ${capsText(d.caps)} · stop at ${Math.round(d.stop_at * 100)}%\n` +
375
+ ` spend ${spendText(d.spend, d.caps)} · paused ${d.paused.length === 0 ? 'none' : d.paused.join(', ')}\n` +
376
+ recordText(d.link, d.files)
377
+ );
378
+ }
379
+
380
+ /**
381
+ * B49b: where the owner reads the run — the live doc's link, else the two Markdown files.
382
+ * @param {string | null} link @param {{binnacle: string, log: string} | null} files @returns {string}
383
+ */
384
+ function recordText(link, files) {
385
+ if (link) return ` link ${link}\n`;
386
+ if (files) return ` link none\n binnacle ${files.binnacle}\n full log ${files.log}\n`;
387
+ return ' link none\n';
388
+ }
389
+
390
+ /**
391
+ * B49b: without a stored link the Markdown files are the record — rewrite them from the ledger now
392
+ * so the printed paths are current. A failed write is reported on stderr and gives null (no paths).
393
+ * @param {string} runId @param {{link?: string | null} | null} grant @param {Date} now
394
+ * @param {(s: string) => void} err
395
+ * @returns {Promise<{binnacle: string, log: string} | null>}
396
+ */
397
+ async function recordFiles(runId, grant, now, err) {
398
+ if (grant === null || grant.link) return null;
399
+ try {
400
+ return await refreshAutopilotFiles(runId, { now });
401
+ } catch (thrown) {
402
+ err(`autopilot: the binnacle files could not be written (${thrown?.message ?? String(thrown)})\n`);
403
+ return null;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * @param {string[]} args @param {AutopilotDeps} [deps]
409
+ * @returns {Promise<number>}
410
+ */
411
+ export async function runAutopilot(args, deps = {}) {
412
+ const { stdout = process.stdout, stderr = process.stderr, now: clock = () => new Date(), writeRow } = deps;
413
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
414
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
415
+ const [sub, ...rest] = args;
416
+ if (!SUBCOMMANDS.includes(sub)) {
417
+ err(USAGE);
418
+ return 2;
419
+ }
420
+ if (sub === 'approve' && rest.some((a) => a === '--yes' || a.startsWith('--yes='))) {
421
+ err(`${APPROVE_NO_YES}\n`);
422
+ return 2;
423
+ }
424
+ try {
425
+ const { flags, positionals } = parseFlags(rest, FLAG_SPECS[/** @type {keyof typeof FLAG_SPECS} */ (sub)]);
426
+ if (positionals.length > 0) throw new StateError('usage', `unexpected argument ${JSON.stringify(positionals[0])}`);
427
+ if (typeof flags.run !== 'string') throw new StateError('usage', `autopilot ${sub} needs --run <id>`);
428
+ const runId = flags.run;
429
+ // B48: ONE writer for every grant / approval / restore / expiry row of this command (signed by
430
+ // the state layer); the run's own ledger unless the caller passed one
431
+ const writer = writeRow ?? (async (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: (await readRun(runId)).project }));
432
+ // B48: every subcommand first restores the approvals whose window is over
433
+ err(approvalCheckText(await checkApprovals(runId, clock, { writeRow: writer })));
434
+ if (sub === 'approve') return await runApprove(runId, flags, { ...deps, writeRow: writer }, out, err);
435
+ if (sub === 'binnacle' || sub === 'log') return await runBinnacle(sub, runId, flags, deps, out, writer);
436
+ if (sub === 'ask') return await runAsk(runId, flags, deps, out);
437
+ if (sub === 'waive' || sub === 'round' || sub === 'level') return await runAction(sub, runId, flags, deps, out, err);
438
+ const now = clock();
439
+
440
+ if (sub === 'status') {
441
+ const { grant, state } = await checkExpiry(runId, now, { writeRow: writer });
442
+ const spend = grant === null ? {} : spendByCategory(await readAllRows((await readRun(runId)).project), runId, grant);
443
+ const files = await recordFiles(runId, grant, now, err);
444
+ const data = statusData(runId, grant, now, state, spend, files);
445
+ out(flags.json ? `${JSON.stringify(data, null, 2)}\n` : statusText(data));
446
+ return 0;
447
+ }
448
+ if (sub === 'stop') {
449
+ // the expiry check first: an expired grant gets its one expire row and no stop row
450
+ const checked = await checkExpiry(runId, now, { writeRow: writer });
451
+ if (checked.state === 'expired') throw new StateError('grant-expired', expiredMessage(runId, checked.grant.until));
452
+ const grant = await stopGrant({ runId, now, writeRow: writer });
453
+ // B49b: the stop row is in; the record the owner reads is the doc's link or the rewritten files
454
+ out(`autopilot run ${runId}: grant ${grant.grant_id} stopped\n${recordText(grant.link ?? null, await recordFiles(runId, grant, now, err))}`);
455
+ return 0;
456
+ }
457
+ // start
458
+ const input = { until: flags.until, delegate: flags.delegate, allow: flags.allow, deny: flags.deny, budget: flags.budget, stopAt: flags['stop-at'] };
459
+ const valid = validateGrantInput(input, now); // refuse bad input before asking anyone
460
+ if ((await readRun(runId)).status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
461
+ const before = await checkExpiry(runId, now, { writeRow: writer });
462
+ if (before.state === 'active') {
463
+ throw new StateError('grant-active', activeGrantMessage(runId, before.grant.until));
464
+ }
465
+ if (!flags.yes) {
466
+ if (!(deps.isTTY ?? process.stdin.isTTY === true)) {
467
+ err('autopilot start: no terminal to confirm; the owner starts autopilot — pass --yes to start it without a prompt\n');
468
+ return 2;
469
+ }
470
+ const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
471
+ const go = await ui.confirm({ message: `Start autopilot for run ${runId} until ${valid.until}?`, initialValue: false });
472
+ if (ui.isCancel(go) || go !== true) {
473
+ out('cancelled — no grant\n');
474
+ return 1;
475
+ }
476
+ }
477
+ // the prompt may have waited: `--until` is checked again (future, at most 24 h) against now
478
+ const startedAt = flags.yes ? now : clock();
479
+ const grant = await startGrant({ runId, input, now: startedAt, writeRow: writer });
480
+ out(
481
+ `autopilot run ${runId}: grant ${grant.grant_id} active until ${grant.until} (${formatLeft(Date.parse(grant.until) - startedAt.getTime())})\n` +
482
+ ` delegate ${grant.delegate} · allow ${grant.scopes.join(', ')} · budget ${capsText(grant.caps)} · stop at ${Math.round(grant.stop_at * 100)}%\n`,
483
+ );
484
+ return 0;
485
+ } catch (thrown) {
486
+ err(`autopilot ${sub}: ${thrown?.message ?? String(thrown)}\n`);
487
+ // `ask` only: an unknown --scope and a malformed question/options are usage; start/status/stop keep B45's codes
488
+ const askUsage = sub === 'ask' && ((thrown instanceof StateError && thrown.code === 'unknown-scope') || (thrown instanceof SessionError && thrown.code === 'usage'));
489
+ const usageError = (thrown instanceof StateError && thrown.code === 'usage') || askUsage;
490
+ return usageError ? 2 : 1;
491
+ }
492
+ }
493
+
494
+ const USAGE =
495
+ 'usage: code-forge autopilot start --run <id> --until <ISO-8601 with offset> --delegate <L2|L3> --allow <scope,…> [--deny <scope,…>] [--budget <category>=<usd>,…] [--stop-at <0..1>] [--yes] | status --run <id> [--json] | stop --run <id> | ask --run <id> --scope <scope> --question <text> [--options a,b,…] [--context-file <path>] [--block <id>] [--file <path>] [--finding <id>] [--json] (options for an action: waive,fix · allow,deny · L1,L2) | waive --run <id> --block <id> --file <path> --finding <id> --severity <warning|nit> --reason <text> --decision <id> | round --run <id> --block <id> --file <path> --decision <id> | level --run <id> --block <id> --plan <file> --decision <id> | approve --run <id> --key <dot.path> --value <json> --until <ISO-8601 with offset> | binnacle --run <id> [--json|--markdown] [--link <https url>] | log --run <id> [--json|--markdown]\n';
496
+
497
+ /** @param {string[]} args @returns {Promise<number>} */
498
+ export default async function autopilot(args) {
499
+ return runAutopilot(args);
500
+ }