@codedology/code-forge 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +23 -3
  3. package/docs/README.md +1 -0
  4. package/docs/reference/blocks.json +96 -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 +1 -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 +2 -0
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/plan.md +2 -0
  15. package/skill/references/review.md +2 -2
  16. package/skill/scripts/forge +1 -1
  17. package/src/autopilot/actions.mjs +491 -0
  18. package/src/autopilot/binnacle.mjs +494 -0
  19. package/src/autopilot/delegate.mjs +422 -0
  20. package/src/autopilot/grant.mjs +354 -0
  21. package/src/autopilot/limits.mjs +847 -0
  22. package/src/autopilot/scopes.mjs +95 -0
  23. package/src/cli/author.mjs +1 -2
  24. package/src/cli/autopilot.mjs +500 -0
  25. package/src/cli/block.mjs +40 -8
  26. package/src/cli/facts.mjs +1 -2
  27. package/src/cli/jev.mjs +1 -2
  28. package/src/cli/logs.mjs +4 -142
  29. package/src/cli/plan.mjs +1 -2
  30. package/src/cli/run.mjs +18 -60
  31. package/src/cli/s2.mjs +1 -2
  32. package/src/cli/spawn.mjs +1 -2
  33. package/src/config/closed-book.mjs +20 -3
  34. package/src/config/load.mjs +15 -0
  35. package/src/doctor/probes.mjs +34 -11
  36. package/src/engines/builders/claude.mjs +3 -3
  37. package/src/engines/builders/codex.mjs +6 -5
  38. package/src/engines/builders/grok.mjs +3 -3
  39. package/src/engines/builders/validate-params.mjs +3 -3
  40. package/src/ledger/report.mjs +2 -0
  41. package/src/review/fixloop.mjs +69 -5
  42. package/src/review/gate-check.mjs +156 -6
  43. package/src/session/spawn.mjs +68 -8
  44. package/src/state/run.mjs +8 -1
  45. package/src/util/breadcrumbs.mjs +1 -0
  46. package/src/util/forbidden.mjs +10 -0
  47. package/src/util/gh.mjs +175 -0
  48. package/src/worker/engine.mjs +8 -4
  49. package/src/worker/loop.mjs +42 -0
@@ -33,7 +33,8 @@
33
33
  * A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
34
34
  * reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
35
35
  * shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
36
- * `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv.
36
+ * `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv — except for
37
+ * the autopilot `delegate` (B46), which the opt-in never opens.
37
38
  *
38
39
  * `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
39
40
  * `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
@@ -57,6 +58,8 @@ import { parseSentinel } from '../engines/sentinel.mjs';
57
58
  import { parseUsage } from '../engines/usage-parse.mjs';
58
59
  import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd, reserveBudget, SPEND_UNREADABLE_MESSAGE, withBudgetLock } from '../ledger/spend.mjs';
59
60
  import { appendRow, readAllRows } from '../ledger/write.mjs';
61
+ import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
62
+ import { StateError } from '../state/paths.mjs';
60
63
  import { exec } from '../util/exec.mjs';
61
64
  import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
62
65
  import { semaphore } from '../util/locks.mjs';
@@ -64,7 +67,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
64
67
  import { redact, writeSafe } from '../util/redact.mjs';
65
68
  import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
66
69
 
67
- export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts']);
70
+ export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
68
71
  export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
69
72
 
70
73
  /** Default wall-clock cap of one foreground session. */
@@ -476,7 +479,7 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
476
479
  * @typedef {object} SessionOpts
477
480
  * @property {Record<string, any>} cfg - the loaded project config (levels, provider).
478
481
  * @property {"L0"|"L1"|"L2"|"L3"} level
479
- * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
482
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
480
483
  * @property {string} promptPath - the brief (coder) or the packet (closed-book).
481
484
  * @property {string} [cwd] - coder: the project tree (default `process.cwd()`); facts (B9b.1): an
482
485
  * existing read-only snapshot of the project to run in, inside `<runRoot>/facts/` or the
@@ -507,6 +510,7 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
507
510
  * @property {NodeJS.ProcessEnv} [env]
508
511
  * @property {(ms: number) => Promise<unknown>} [sleep] - B40: the rate-limit backoff wait (tests: a recorder).
509
512
  * @property {() => number} [random] - B40: the backoff jitter source, in [0, 1) (default `Math.random`).
513
+ * @property {() => Date} [now] - B48: the clock the autopilot grant is checked against (default: now).
510
514
  */
511
515
 
512
516
  /**
@@ -761,6 +765,14 @@ function sessionUsd(provider, level, usage) {
761
765
  * cost (`estimateSessionUsd`: the packet's bytes / 4 in, a fixed tokens_out per role and level)
762
766
  * run under the run's budget lock, so concurrent sessions of one run see each other's holds. No
763
767
  * budget (or no run) ⇒ no lock, no reservation.
768
+ *
769
+ * B48: with an active autopilot grant that caps the session's category (coder → coding, every
770
+ * other role → review), the same lock also runs the category check (`categoryGate`, after the
771
+ * `budget.usd` check): at `stop_at` × cap the session is refused, the grant pauses for the
772
+ * category and one signed `autopilot.pause` row is written; otherwise the estimate is reserved in
773
+ * the category too. The plan is re-read UNDER the lock, so the next session sees the pause the
774
+ * previous one wrote. A run record that cannot be read refuses with reason `autopilot-unknown`.
775
+ * No grant ⇒ exactly the B41 behaviour.
764
776
  * @param {SessionOpts} opts @param {SessionDeps} deps
765
777
  * @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
766
778
  * @param {{write: (s: string) => unknown}} stderr
@@ -769,17 +781,65 @@ function sessionUsd(provider, level, usage) {
769
781
  * the refusal result, or the (idempotent) release of this attempt's reservation.
770
782
  */
771
783
  async function budgetGate(opts, deps, writeRow, stderr, step) {
772
- const budget = budgetUsdOf(opts.cfg);
773
- if (budget === null || typeof opts.run !== 'string' || opts.run.length === 0) return { refused: null, release: () => {} };
784
+ const none = { refused: null, release: () => {} };
785
+ if (typeof opts.run !== 'string' || opts.run.length === 0) return none;
774
786
  const run = opts.run;
787
+ const budget = budgetUsdOf(opts.cfg);
788
+ // B48: an active autopilot grant that caps this role's category (null: nothing to meter). Read
789
+ // once here only to decide whether to lock at all, and again under the lock (below).
790
+ const now = (deps.now ?? (() => new Date()))();
791
+ const outside = await readCategoryPlan(run, opts.role, now);
792
+ if (budget === null && outside.plan === null && !outside.unknown) return none;
775
793
  const estimate = estimateSessionUsd({ provider: step.provider, level: opts.level, role: opts.role, tokensIn: packetTokens(opts) });
776
794
  return withBudgetLock(run, async () => {
777
- const refused = await checkGate(opts, deps, writeRow, stderr, budget, estimate);
778
- if (refused) return { refused };
779
- return { refused: null, release: reserveBudget(run, estimate) };
795
+ // under the lock: a fresh clock (the lock may have waited) and a fresh plan, so a pause the
796
+ // previous session wrote is seen here
797
+ const lockedNow = (deps.now ?? (() => new Date()))();
798
+ const { plan, unknown } = await readCategoryPlan(run, opts.role, lockedNow);
799
+ if (unknown) {
800
+ writeSafe(stderr, `code-forge: ${RECORD_UNKNOWN_MESSAGE}\n`);
801
+ return { refused: { status: 'unavailable', reason: 'autopilot-unknown', message: RECORD_UNKNOWN_MESSAGE, answer: null } };
802
+ }
803
+ if (budget !== null) {
804
+ const refused = await checkGate(opts, deps, writeRow, stderr, budget, estimate);
805
+ if (refused) return { refused };
806
+ }
807
+ let releaseCategory = () => {};
808
+ if (plan !== null) {
809
+ const slug = opts.slug ?? plan.project;
810
+ const readRows = deps.readRows ?? (() => readAllRows(slug));
811
+ const gate = await categoryGate({ plan, run, role: opts.role, block: opts.block ?? null, readRows, writeRow, stderr, now: lockedNow, estimate });
812
+ if (gate.refused) return { refused: /** @type {SessionResult} */ (gate.refused) };
813
+ releaseCategory = gate.release;
814
+ }
815
+ const releaseRun = budget !== null ? reserveBudget(run, estimate) : () => {};
816
+ return {
817
+ refused: null,
818
+ release: () => {
819
+ releaseRun();
820
+ releaseCategory();
821
+ },
822
+ };
780
823
  });
781
824
  }
782
825
 
826
+ /**
827
+ * B48: the category plan, with a known record-read failure turned into `unknown` (fail closed:
828
+ * a grant we cannot read is never "no grant"); any other error propagates. Accepted on purpose:
829
+ * an unreadable run record refuses the session even for a run without a grant — the record is
830
+ * what says whether a grant exists.
831
+ * @param {string} run @param {string} role @param {Date} now
832
+ * @returns {Promise<{plan: import('../autopilot/limits.mjs').CategoryPlan | null, unknown: boolean}>}
833
+ */
834
+ async function readCategoryPlan(run, role, now) {
835
+ try {
836
+ return { plan: await categoryPlan(run, role, now), unknown: false };
837
+ } catch (thrown) {
838
+ if (thrown instanceof StateError && thrown.code === 'record-unknown') return { plan: null, unknown: true };
839
+ throw thrown;
840
+ }
841
+ }
842
+
783
843
  /**
784
844
  * The packet's (or brief's) tokens plus the system prompt's, by the ledger's bytes / 4 rule (an
785
845
  * unreadable packet counts 0 bytes; tokens_out is still reserved).
package/src/state/run.mjs CHANGED
@@ -205,14 +205,20 @@ export async function startRun(opts) {
205
205
  * source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
206
206
  * the old/new snapshot hashes. A row that cannot be written leaves the reload in place and is
207
207
  * reported as `rowError`.
208
+ *
209
+ * B48 `guard`: called under the lock after the fixed-key check, with the record, the snapshot in
210
+ * force (undefined for a record from before snapshots), the new config and the changed key paths
211
+ * (null without a snapshot); it throws to refuse the reload (nothing written). The autopilot limit
212
+ * guard (`src/autopilot/limits.mjs`) uses it.
208
213
  * @param {{
209
214
  * runId: string, config: Record<string, any>, readPending?: () => string[], effectiveSlug?: string,
210
215
  * writeRow: WriteRow, now?: Date,
216
+ * guard?: (record: Record<string, any>, before: Record<string, any> | undefined, config: Record<string, any>, changed: string[] | null) => void | Promise<void>,
211
217
  * }} opts
212
218
  * @returns {Promise<{changed: string[] | null, oldHash: string | null, newHash: string, pinned: number, rowError: string | null}>}
213
219
  * `changed` is `[]` when nothing changed.
214
220
  */
215
- export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date() }) {
221
+ export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date(), guard }) {
216
222
  return withRunLock(runId, async () => {
217
223
  const current = await readRun(runId);
218
224
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
@@ -238,6 +244,7 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
238
244
  throw new StateError('immutable-key', `${why.join('; ')} — put ${frozen.length === 1 ? 'it' : 'them'} back, or end the run and start a new one`);
239
245
  }
240
246
  const changed = state ? changedKeyPaths(before, config) : null;
247
+ if (guard) await guard(current, state ? before : undefined, config, changed);
241
248
  /** @type {Record<string, string>} */
242
249
  const pins = {};
243
250
  const oldPins = state?.pins && typeof state.pins === 'object' ? state.pins : {};
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  * @type {Readonly<Record<string, readonly string[]>>}
26
26
  */
27
27
  export const SUBCOMMANDS = Object.freeze({
28
+ autopilot: ['start', 'status', 'stop'],
28
29
  block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
29
30
  gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
30
31
  jev: ['ask'],
@@ -276,6 +276,7 @@ export const FORBIDDEN = Object.freeze(
276
276
  * They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
277
277
  * always appends them, so every list built for a coder carries them.
278
278
  * - `code-forge block waive` — waiving a review finding is human-only;
279
+ * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
279
280
  * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
280
281
  * @type {ReadonlyArray<ForbiddenEntry>}
281
282
  */
@@ -290,6 +291,15 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
290
291
  ],
291
292
  description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
292
293
  },
294
+ {
295
+ id: 'code-forge-autopilot-from-coder',
296
+ kind: 'anyPrefix',
297
+ prefixes: [
298
+ ['code-forge', 'autopilot'],
299
+ ['forge', 'autopilot'],
300
+ ],
301
+ description: 'Any autopilot command (forge autopilot start / status / stop / …) — the owner grants, reads and ends autopilot, never a coder',
302
+ },
293
303
  {
294
304
  id: 'code-forge-no-require-reviews',
295
305
  kind: 'contains',
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Shared `gh` helpers (moved out of `cli/logs.mjs`, B27/B28, for B47's tracked waiver issues):
3
+ * the secret check a body must pass before it leaves the machine, a PATH lookup, and the `gh`
4
+ * calls — argv only, never a shell, never gh's output in a message (only `exit n`).
5
+ *
6
+ * - {@link findSecret}: the first secret-shaped text in a body (rule name and line only).
7
+ * - {@link pathLookup}: whether a command is an executable on PATH.
8
+ * - {@link ghRun}: one `gh` call that never throws.
9
+ * - {@link ghWithBodyFile}: a `gh` call whose body goes through a private temp file (removed after).
10
+ * - {@link findReported}: an earlier issue holding a marker in its body.
11
+ */
12
+
13
+ import { accessSync, constants as fsConstants, statSync } from 'node:fs';
14
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { SECRET_LOOKING_PATTERNS } from '../config/secret-patterns.mjs';
17
+ import { exec as realExec } from './exec.mjs';
18
+ import { currentRunRoot } from './tmp.mjs';
19
+
20
+ /** Every `gh` call gets at most this long. */
21
+ export const GH_TIMEOUT_MS = 60_000;
22
+
23
+ /**
24
+ * Secret shapes the final report is checked for, after scrubbing. A hit stops the report.
25
+ * @type {ReadonlyArray<{name: string, re: RegExp}>}
26
+ */
27
+ export const REPORT_SECRET_RULES = Object.freeze([
28
+ { name: 'API key (sk-)', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/ },
29
+ { name: 'GitHub token', re: /(?<![A-Za-z0-9_])(?:gh[opsur]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },
30
+ { name: 'Slack token', re: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/ },
31
+ { name: 'AWS access key', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/ },
32
+ { name: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
33
+ { name: 'JWT', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/ },
34
+ // copied without g/y: `.test()` must carry no `lastIndex` state between checks
35
+ ...SECRET_LOOKING_PATTERNS.map((re) => ({ name: 'secret-shaped token', re: new RegExp(re.source, re.flags.replace(/[gy]/g, '')) })),
36
+ ]);
37
+
38
+ /** @param {string} s @returns {number} Shannon entropy in bits per character */
39
+ export function entropy(s) {
40
+ /** @type {Map<string, number>} */
41
+ const freq = new Map();
42
+ for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
43
+ let bits = 0;
44
+ for (const n of freq.values()) {
45
+ const p = n / s.length;
46
+ bits -= p * Math.log2(p);
47
+ }
48
+ return bits;
49
+ }
50
+
51
+ /**
52
+ * A 32+ character run of `[A-Za-z0-9+/_=-]` with at least two of upper/lower/digit and entropy of
53
+ * at least 4 bits per character. A hex-only run (`0-9a-f`, either case) is never flagged here: a
54
+ * hash using all 16 digits evenly reaches exactly 4 bits and would otherwise trip the rule; the
55
+ * token-shape rules ({@link REPORT_SECRET_RULES}) still apply to it.
56
+ * @param {string} line
57
+ * @returns {boolean}
58
+ */
59
+ function hasHighEntropyRun(line) {
60
+ for (const m of line.matchAll(/[A-Za-z0-9+/_=-]{32,}/g)) {
61
+ const run = m[0];
62
+ if (/^[0-9a-f]+$/i.test(run)) continue; // a hash, not a token
63
+ const classes = [/[A-Z]/, /[a-z]/, /[0-9]/].filter((re) => re.test(run)).length;
64
+ if (classes >= 2 && entropy(run) >= 4) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * The first secret-shaped text in `text` (rule name and 1-based line), or null. The matched text
71
+ * itself is never returned.
72
+ * @param {string} text
73
+ * @returns {{rule: string, line: number}|null}
74
+ */
75
+ export function findSecret(text) {
76
+ const lines = text.split('\n');
77
+ for (let i = 0; i < lines.length; i += 1) {
78
+ for (const r of REPORT_SECRET_RULES) if (r.re.test(lines[i])) return { rule: r.name, line: i + 1 };
79
+ if (hasHighEntropyRun(lines[i])) return { rule: 'high-entropy string', line: i + 1 };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** @param {string} pathEnv @returns {(cmd: string) => boolean} */
85
+ export function pathLookup(pathEnv) {
86
+ return (cmd) =>
87
+ pathEnv.split(path.delimiter).some((dir) => {
88
+ if (!dir) return false;
89
+ try {
90
+ const f = path.join(dir, cmd);
91
+ accessSync(f, fsConstants.X_OK);
92
+ return statSync(f).isFile();
93
+ } catch {
94
+ return false;
95
+ }
96
+ });
97
+ }
98
+
99
+ /**
100
+ * One `gh` call; a throw from `exec` is a `failed` result with no output.
101
+ * @param {typeof realExec} exec @param {string[]} argv @param {NodeJS.ProcessEnv} env
102
+ * @param {{cwd?: string}} [opts]
103
+ * @returns {Promise<{result: string, code: number|null, timedOut: boolean, stdout: string, stderr: string}>}
104
+ */
105
+ export async function ghRun(exec, argv, env, opts = {}) {
106
+ try {
107
+ return await exec(argv, { env, timeoutMs: GH_TIMEOUT_MS, ...(opts.cwd ? { cwd: opts.cwd } : {}) });
108
+ } catch {
109
+ return { result: 'failed', code: null, timedOut: false, stdout: '', stderr: '' };
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Write `text` to a private temp file and run the first argv of `argvsFor(file)`; when it fails
115
+ * over a label, run the next one (once).
116
+ * @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} text
117
+ * @param {(file: string) => string[][]} argvsFor
118
+ * @param {(stdout: string) => void} [onOk]
119
+ * @returns {Promise<string|null>} null when gh succeeded; else why (`exit n`, never its output).
120
+ */
121
+ export async function ghWithBodyFile(exec, env, text, argvsFor, onOk) {
122
+ /** @type {string|null} */
123
+ let dir = null;
124
+ try {
125
+ let res;
126
+ try {
127
+ const root = currentRunRoot();
128
+ await mkdir(root, { recursive: true });
129
+ dir = await mkdtemp(path.join(root, 'report-'));
130
+ const bodyFile = path.join(dir, 'body.md');
131
+ await writeFile(bodyFile, text, { encoding: 'utf8', mode: 0o600 });
132
+ const [first, retry] = argvsFor(bodyFile);
133
+ res = await ghRun(exec, first, env);
134
+ // gh's stderr is matched to decide the retry, never printed
135
+ if (res.result !== 'ok' && retry && /label/i.test(res.stderr)) res = await ghRun(exec, retry, env);
136
+ } catch {
137
+ return 'could not write the report file';
138
+ }
139
+ if (res.result !== 'ok') return res.code !== null ? `exit ${res.code}` : res.timedOut ? 'timed out' : 'did not finish';
140
+ onOk?.(res.stdout);
141
+ return null;
142
+ } finally {
143
+ if (dir !== null) await rm(dir, { recursive: true, force: true }).catch(() => {});
144
+ }
145
+ }
146
+
147
+ /**
148
+ * An earlier issue holding `needle` in its body (open or closed): `{hit}` (null when there is
149
+ * none), or `{why}` when the search failed (`gh exit n`; never gh's output). With `opts.match`
150
+ * the bodies are fetched too and a hit must contain that exact text (GitHub's search is fuzzy);
151
+ * then a full page (`opts.limit` results, default 5) with no exact hit is `{why: 'too-many-results'}`
152
+ * — the match may be on a page not read, so the caller must not assume there is none.
153
+ * @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} repo @param {string} needle
154
+ * @param {{match?: string, limit?: number}} [opts]
155
+ * @returns {Promise<{hit: {number: number, url: string, state: string}|null} | {why: string}>}
156
+ */
157
+ export async function findReported(exec, env, repo, needle, opts = {}) {
158
+ const fields = opts.match === undefined ? 'number,url,state' : 'number,url,state,body';
159
+ const limit = Number.isInteger(opts.limit) && /** @type {number} */ (opts.limit) > 0 ? /** @type {number} */ (opts.limit) : 5;
160
+ const res = await ghRun(exec, ['gh', 'issue', 'list', '--repo', repo, '--state', 'all', '--search', `${needle} in:body`, '--json', fields, '--limit', String(limit)], env);
161
+ if (res.result !== 'ok') return { why: res.code !== null ? `gh exit ${res.code}` : res.timedOut ? 'gh timed out' : 'gh did not finish' };
162
+ let list;
163
+ try {
164
+ list = JSON.parse(res.stdout);
165
+ } catch {
166
+ return { why: 'gh gave no list' };
167
+ }
168
+ if (!Array.isArray(list)) return { why: 'gh gave no list' };
169
+ const match = opts.match;
170
+ const hit = list.find(
171
+ (i) => Number.isSafeInteger(i?.number) && typeof i?.url === 'string' && /^https:\/\//.test(i.url) && (match === undefined || (typeof i.body === 'string' && i.body.includes(match))),
172
+ );
173
+ if (!hit && match !== undefined && list.length >= limit) return { why: 'too-many-results' };
174
+ return { hit: hit ? { number: hit.number, url: hit.url, state: typeof hit.state === 'string' ? hit.state.toLowerCase() : 'unknown' } : null };
175
+ }
@@ -42,7 +42,7 @@ import { planAndRecord, tierOf } from '../review/budget.mjs';
42
42
  import { budgetFor } from '../review/packet.mjs';
43
43
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
44
44
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
45
- import { newFileState, runRound } from '../review/fixloop.mjs';
45
+ import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
46
46
  import { readRun } from '../state/run.mjs';
47
47
  import { redact } from '../util/redact.mjs';
48
48
  import { keyedLock } from '../util/locks.mjs';
@@ -297,6 +297,9 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
297
297
  let state = loaded.state;
298
298
  if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
299
299
  state.l3_rung_used = state.l3_rung_used || rungUsed;
300
+ // B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
301
+ const extraRounds = extraRoundsFor(rows, { runId: ctx.runId, block: ticket.block, file: ticket.file, key });
302
+ reopenForExtraRound(state, extraRounds, ctx.cfg);
300
303
 
301
304
  if (state.status === 'stopped') {
302
305
  return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
@@ -310,7 +313,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
310
313
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
311
314
  }
312
315
  const fixed = state;
313
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt });
316
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds });
314
317
  // a round that could take the rung keeps the block lock until its state is saved
315
318
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
316
319
  return { round };
@@ -321,10 +324,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
321
324
  /**
322
325
  * Run the prepared round and save the file's state: the ticket's outcome.
323
326
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
324
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string}} opts
327
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number}} opts
325
328
  * @returns {Promise<ReviewOutcome>}
326
329
  */
327
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt }) {
330
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0 }) {
328
331
  /** @type {Record<string, any> | null} */
329
332
  let engineOutcome = null;
330
333
  let approvalWritten = false;
@@ -356,6 +359,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
356
359
  base,
357
360
  writeRow,
358
361
  factsExcerpt,
362
+ extraRounds,
359
363
  ...(jev ? { jev } : {}),
360
364
  review: async () => {
361
365
  engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
@@ -37,6 +37,7 @@
37
37
  import { spawn as nodeSpawn } from 'node:child_process';
38
38
  import path from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
40
+ import { expiryChecks } from '../autopilot/limits.mjs';
40
41
  import { loadProjectConfig } from '../config/load.mjs';
41
42
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
42
43
  import { appendRow, readAllRows } from '../ledger/write.mjs';
@@ -44,7 +45,9 @@ import { spawnSession } from '../session/spawn.mjs';
44
45
  import { snapshotFor } from '../state/config-snapshot.mjs';
45
46
  import { readRun, reattachWorker } from '../state/run.mjs';
46
47
  import { loadKey, signRow } from '../state/signer.mjs';
48
+ import { logWarning } from '../util/error-log.mjs';
47
49
  import { keyedLock } from '../util/locks.mjs';
50
+ import { writeSafe } from '../util/redact.mjs';
48
51
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
49
52
  import { reviewTicket } from './engine.mjs';
50
53
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
@@ -58,6 +61,24 @@ export const DEFAULT_SESSION_TIMEOUT_S = 300;
58
61
 
59
62
  /** B42: `review.parallel_tickets` when the config does not set a valid one. */
60
63
  export const DEFAULT_PARALLEL_TICKETS = 3;
64
+
65
+ /** B48: the fixed text of a failed autopilot expiry check in a drain (stderr and the warning log). */
66
+ export const EXPIRY_CHECK_FAILED = 'the autopilot expiry check failed in a worker drain; it runs again at the next drain';
67
+
68
+ /**
69
+ * B48: what kind of error an expiry check threw — its code or class only, NEVER its message (a
70
+ * message can carry a path or a value): `EACCES`, `StateError:record-unknown`, `TypeError`.
71
+ * Anything that does not look like a plain identifier is `Error`.
72
+ * @param {unknown} thrown @returns {string}
73
+ */
74
+ export function errorKind(thrown) {
75
+ if (!(thrown instanceof Error)) return 'Error';
76
+ const err = /** @type {any} */ (thrown);
77
+ const name = typeof err.name === 'string' ? err.name : 'Error';
78
+ const code = typeof err?.code === 'string' ? err.code : null;
79
+ const kind = code === null ? name : /^E[A-Z]+$/.test(code) ? code : `${name}:${code}`;
80
+ return /^[A-Za-z0-9_:.-]{1,64}$/.test(kind) ? kind : 'Error';
81
+ }
61
82
  export const MAX_PARALLEL_TICKETS = 16;
62
83
 
63
84
  /**
@@ -166,6 +187,10 @@ export function loopResult(outcome) {
166
187
  * @property {{write: (s: string) => unknown}} [stderr]
167
188
  * @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
168
189
  * inject a mock); default: `askJev` with the resolved Jev key, none without one.
190
+ * @property {() => Promise<unknown>} [expiryCheck] - B48: the lazy autopilot checks, run once at the
191
+ * start of every drain, before any ticket; default: `expiryChecks(opts.runId, <now>, {writeRow})`
192
+ * (grant expiry, expired approvals). A failure is never silent: one stderr line and one
193
+ * `autopilot_expiry_check_failed` warning per streak of failures; the next drain tries again.
169
194
  */
170
195
 
171
196
  /**
@@ -180,6 +205,10 @@ export async function createWorker(opts, deps = {}) {
180
205
  const loadTicket = deps.readTicket ?? readTicket;
181
206
  const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
182
207
  const readRows = deps.readRows ?? (() => readAllRows(slug));
208
+ // B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
209
+ const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
210
+ const warnStream = deps.stderr ?? process.stderr;
211
+ let expiryFailing = false; // inside a streak of failed checks: warn once per streak
183
212
  const store = deps.store ?? (await createDefaultKeyStore(env));
184
213
  const jevKey = await resolveJevKey(cfg, { store, env, opRead: deps.opRead });
185
214
  const childEnv = sessionEnv(env, [jevKey, key.toString('hex')]);
@@ -420,6 +449,19 @@ export async function createWorker(opts, deps = {}) {
420
449
  * @returns {Promise<number>} how many were completed.
421
450
  */
422
451
  async function drain() {
452
+ // B48: the lazy autopilot checks (grant expiry, expired approvals) before any ticket starts;
453
+ // a failed check never stops the drain (the next drain tries again) and is never silent
454
+ try {
455
+ await expiryCheck();
456
+ expiryFailing = false;
457
+ } catch (thrown) {
458
+ if (!expiryFailing) {
459
+ expiryFailing = true;
460
+ const text = `${EXPIRY_CHECK_FAILED} (${errorKind(thrown)})`;
461
+ writeSafe(warnStream, `worker: WARN ${text}\n`);
462
+ await logWarning({ warning: 'autopilot_expiry_check_failed', message: text }).catch(() => null);
463
+ }
464
+ }
423
465
  /** @type {number | null} */
424
466
  let limit = null;
425
467
  let done = 0;