@codedology/code-forge 0.5.1 → 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 +37 -0
  2. package/README.md +13 -0
  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
package/src/cli/block.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
3
3
  * `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
4
4
  *
5
- * block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file>
5
+ * block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
6
6
  * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
7
7
  * block attempt <id> --run <r>
8
8
  * block rebase <id> --run <r> [--head <ref>]
@@ -33,6 +33,11 @@
33
33
  * FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
34
34
  * way out). It runs after the review rows and reuses their file set (listed once).
35
35
  *
36
+ * B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
37
+ * latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
38
+ * neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
39
+ * explicit `--level` always wins; a repeated or valueless `--level` is a usage error as before.
40
+ *
36
41
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
37
42
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
38
43
  * through `closeBlock`'s `extraChecks`.
@@ -41,13 +46,14 @@
41
46
  import { readFile, realpath } from 'node:fs/promises';
42
47
  import path from 'node:path';
43
48
  import { parse as parseYAML } from 'yaml';
49
+ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
44
50
  import { appendRow, readAllRows } from '../ledger/write.mjs';
45
51
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, stopBlock } from '../state/block.mjs';
46
52
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
47
53
  import { StateError } from '../state/paths.mjs';
48
54
  import { findOverlap } from '../state/registry.mjs';
49
55
  import { readRun, writeSigned } from '../state/run.mjs';
50
- import { loadKey } from '../state/signer.mjs';
56
+ import { loadKey, verifyRow } from '../state/signer.mjs';
51
57
  import { blockFileSet, reviewGateCheck, waiveFinding } from '../review/gate-check.mjs';
52
58
  import { writeSafe } from '../util/redact.mjs';
53
59
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
@@ -58,7 +64,7 @@ import { gitChildEnv } from '../worker/ticket.mjs';
58
64
  import { missingReviewTickets } from '../engines/sentinel.mjs';
59
65
 
60
66
  const USAGE =
61
- 'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
67
+ 'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
62
68
  ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
63
69
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
64
70
 
@@ -67,7 +73,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
67
73
 
68
74
  /**
69
75
  * @param {string[]} args
70
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe}} [deps]
76
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date}} [deps] -
77
+ * `now` (B48): the clock of the autopilot expiry checks.
71
78
  * @returns {Promise<number>}
72
79
  */
73
80
  export async function runBlock(args, deps = {}) {
@@ -84,7 +91,7 @@ export async function runBlock(args, deps = {}) {
84
91
  const [id, extra] = positionals;
85
92
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
86
93
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
87
- if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || typeof flags.level !== 'string')) {
94
+ if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
88
95
  throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
89
96
  }
90
97
  if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
@@ -93,8 +100,18 @@ export async function runBlock(args, deps = {}) {
93
100
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
94
101
  const record = await readRun(runId);
95
102
  const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
103
+ // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
104
+ // a failed check is a warning, never a reason to stop the block command
105
+ try {
106
+ err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
107
+ } catch (thrown) {
108
+ err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
109
+ }
96
110
 
97
111
  if (sub === 'open') {
112
+ // B47: no --level ⇒ the level autopilot chose for the block (a signed autopilot.level row)
113
+ const level = typeof flags.level === 'string' ? flags.level : await autopilotLevel(runId, id, record);
114
+ if (level === null) throw new StateError('usage', `no --level given and no autopilot level recorded for ${id}`);
98
115
  const acceptance = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
99
116
  let brief;
100
117
  if (typeof flags.brief === 'string') {
@@ -106,7 +123,7 @@ export async function runBlock(args, deps = {}) {
106
123
  const { block, pointer } = await openBlock({
107
124
  runId,
108
125
  id,
109
- level: /** @type {string} */ (flags.level),
126
+ level,
110
127
  owned: /** @type {string[]} */ (flags.owned),
111
128
  acceptance,
112
129
  attempt: intFlag(flags.attempt, 'attempt'),
@@ -117,8 +134,9 @@ export async function runBlock(args, deps = {}) {
117
134
  cfg: await openConfig(record.workspace),
118
135
  writeRow,
119
136
  });
120
- const raised = block.level !== flags.level ? ` (${block.kind} floor, asked ${flags.level})` : '';
121
- out(`block ${id} open · ${block.level}${raised} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
137
+ const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
138
+ const chosen = typeof flags.level === 'string' ? '' : ' (autopilot level)';
139
+ out(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
122
140
  out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
123
141
  return 0;
124
142
  }
@@ -204,6 +222,20 @@ export async function runBlock(args, deps = {}) {
204
222
  return 2;
205
223
  }
206
224
 
225
+ /**
226
+ * B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
227
+ * run for it whose MAC verifies — or null when there is none.
228
+ * @param {string} runId @param {string} id @param {Record<string, any>} record
229
+ * @returns {Promise<string | null>}
230
+ */
231
+ async function autopilotLevel(runId, id, record) {
232
+ const rows = (await readAllRows(record.project)).filter((r) => r?.event === 'autopilot.level' && r.run === runId && r.block === id && typeof r.level === 'string');
233
+ if (rows.length === 0) return null;
234
+ const key = await loadKey(runId);
235
+ const valid = rows.filter((r) => verifyRow(r, key).ok);
236
+ return valid.length === 0 ? null : valid[valid.length - 1].level;
237
+ }
238
+
207
239
  /**
208
240
  * The project config for `block open`'s coder floor (B34): the loaded `.code-forge.yml`, or
209
241
  * undefined when there is none or it does not load — the floor then takes its default (L1), the
package/src/cli/facts.mjs CHANGED
@@ -11,13 +11,12 @@
11
11
 
12
12
  import { accessSync, constants, statSync } from 'node:fs';
13
13
  import path from 'node:path';
14
- import { loadProjectConfig } from '../config/load.mjs';
14
+ import { loadProjectConfig, slugFor } from '../config/load.mjs';
15
15
  import { buildFacts, FactsError } from '../session/facts.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 { redact, writeSafe } from '../util/redact.mjs';
20
- import { slugFor } from './run.mjs';
21
20
  import { exitCodeFor } from './spawn.mjs';
22
21
 
23
22
  /**
package/src/cli/jev.mjs CHANGED
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { randomUUID } from 'node:crypto';
33
33
  import { readFile } from 'node:fs/promises';
34
- import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
34
+ import { loadConfigFile, DEFAULT_CONFIG_FILENAME, slugFor } from '../config/load.mjs';
35
35
  import path from 'node:path';
36
36
  import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
37
37
  import { askJev as realAskJev } from '../decide/jev-client.mjs';
@@ -40,7 +40,6 @@ import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
40
40
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
41
41
  import { appendRow as realAppendRow } from '../ledger/write.mjs';
42
42
  import { writeSafe } from '../util/redact.mjs';
43
- import { slugFor } from './run.mjs';
44
43
 
45
44
  const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>] [--block <id>] [--plan <file>] [--rules]\n';
46
45
 
package/src/cli/logs.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  import { spawn } from 'node:child_process';
36
- import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
36
+ import { readFileSync } from 'node:fs';
37
37
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
38
38
  import os from 'node:os';
39
39
  import path from 'node:path';
@@ -43,7 +43,7 @@ import { NO_ERROR_LOG_ENV, capBytes, createScrubber, describeCounts, errorLogPat
43
43
  import { compareVersions, fixStatus, loadKnownFixes } from '../util/known-fixes.mjs';
44
44
  import { redact, writeSafe } from '../util/redact.mjs';
45
45
  import { currentRunRoot } from '../util/tmp.mjs';
46
- import { SECRET_LOOKING_PATTERNS } from '../config/secret-patterns.mjs';
46
+ import { GH_TIMEOUT_MS, findReported, findSecret, ghWithBodyFile, pathLookup } from '../util/gh.mjs';
47
47
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
48
48
  import { AI_REPORT_MAX_BYTES, applyItems, describeAiCounts, runAiScrub } from '../session/scrub.mjs';
49
49
 
@@ -83,7 +83,8 @@ export const DISCLOSURE = disclosure();
83
83
 
84
84
  export const CONFIRM_MESSAGE = 'This will be public on GitHub. Send it?';
85
85
 
86
- const GH_TIMEOUT_MS = 60_000;
86
+ // the secret check and the gh helpers live in `util/gh.mjs` (shared with B47's waiver issues)
87
+ export { REPORT_SECRET_RULES, entropy, findSecret } from '../util/gh.mjs';
87
88
  const NPM_TIMEOUT_MS = 10_000;
88
89
  /** `code-forge doctor --json` for `--with-doctor` gets at most this long. */
89
90
  export const DOCTOR_TIMEOUT_MS = 60_000;
@@ -117,64 +118,6 @@ export const PACKAGE_NAME = '@codedology/code-forge';
117
118
  * the editor on the terminal (default: argv only, stdio inherited, never a shell).
118
119
  */
119
120
 
120
- /**
121
- * Secret shapes the final report is checked for, after scrubbing. A hit stops the report.
122
- * @type {ReadonlyArray<{name: string, re: RegExp}>}
123
- */
124
- export const REPORT_SECRET_RULES = Object.freeze([
125
- { name: 'API key (sk-)', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/ },
126
- { name: 'GitHub token', re: /(?<![A-Za-z0-9_])(?:gh[opsur]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },
127
- { name: 'Slack token', re: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/ },
128
- { name: 'AWS access key', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/ },
129
- { name: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
130
- { name: 'JWT', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/ },
131
- // copied without g/y: `.test()` must carry no `lastIndex` state between checks
132
- ...SECRET_LOOKING_PATTERNS.map((re) => ({ name: 'secret-shaped token', re: new RegExp(re.source, re.flags.replace(/[gy]/g, '')) })),
133
- ]);
134
-
135
- /** @param {string} s @returns {number} Shannon entropy in bits per character */
136
- export function entropy(s) {
137
- /** @type {Map<string, number>} */
138
- const freq = new Map();
139
- for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
140
- let bits = 0;
141
- for (const n of freq.values()) {
142
- const p = n / s.length;
143
- bits -= p * Math.log2(p);
144
- }
145
- return bits;
146
- }
147
-
148
- /**
149
- * A 32+ character run of `[A-Za-z0-9+/_=-]` with at least two of upper/lower/digit and entropy of
150
- * at least 4 bits per character (a hex hash tops out at 4 and is not flagged; a random token is).
151
- * @param {string} line
152
- * @returns {boolean}
153
- */
154
- function hasHighEntropyRun(line) {
155
- for (const m of line.matchAll(/[A-Za-z0-9+/_=-]{32,}/g)) {
156
- const run = m[0];
157
- const classes = [/[A-Z]/, /[a-z]/, /[0-9]/].filter((re) => re.test(run)).length;
158
- if (classes >= 2 && entropy(run) >= 4) return true;
159
- }
160
- return false;
161
- }
162
-
163
- /**
164
- * The first secret-shaped text in `text` (rule name and 1-based line), or null. The matched text
165
- * itself is never returned.
166
- * @param {string} text
167
- * @returns {{rule: string, line: number}|null}
168
- */
169
- export function findSecret(text) {
170
- const lines = text.split('\n');
171
- for (let i = 0; i < lines.length; i += 1) {
172
- for (const r of REPORT_SECRET_RULES) if (r.re.test(lines[i])) return { rule: r.name, line: i + 1 };
173
- if (hasHighEntropyRun(lines[i])) return { rule: 'high-entropy string', line: i + 1 };
174
- }
175
- return null;
176
- }
177
-
178
121
  /**
179
122
  * `owner/repo` from package.json `bugs.url`, else `repository` (string or `{url}`).
180
123
  * @param {any} [pkg]
@@ -197,21 +140,6 @@ export function issueRepo(pkg) {
197
140
  return null;
198
141
  }
199
142
 
200
- /** @param {string} pathEnv @returns {(cmd: string) => boolean} */
201
- function pathLookup(pathEnv) {
202
- return (cmd) =>
203
- pathEnv.split(path.delimiter).some((dir) => {
204
- if (!dir) return false;
205
- try {
206
- const f = path.join(dir, cmd);
207
- accessSync(f, fsConstants.X_OK);
208
- return statSync(f).isFile();
209
- } catch {
210
- return false;
211
- }
212
- });
213
- }
214
-
215
143
  /** @param {ErrorEntry} e */
216
144
  function command(e) {
217
145
  return e.sub ? `${e.verb} ${e.sub}` : e.verb;
@@ -895,72 +823,6 @@ async function editBody(body, env, deps, out) {
895
823
  }
896
824
  }
897
825
 
898
- /**
899
- * An earlier issue holding `fp` in its body (open or closed): `{hit}` (null when there is none),
900
- * or `{why}` when the search failed (`gh exit n`; never gh's output).
901
- * @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} repo @param {string} fp
902
- * @returns {Promise<{hit: {number: number, url: string, state: string}|null} | {why: string}>}
903
- */
904
- async function findReported(exec, env, repo, fp) {
905
- const res = await ghRun(exec, ['gh', 'issue', 'list', '--repo', repo, '--state', 'all', '--search', `${fp} in:body`, '--json', 'number,url,state', '--limit', '5'], env);
906
- if (res.result !== 'ok') return { why: res.code !== null ? `gh exit ${res.code}` : res.timedOut ? 'gh timed out' : 'gh did not finish' };
907
- let list;
908
- try {
909
- list = JSON.parse(res.stdout);
910
- } catch {
911
- return { why: 'gh gave no list' };
912
- }
913
- if (!Array.isArray(list)) return { why: 'gh gave no list' };
914
- const hit = list.find((i) => Number.isSafeInteger(i?.number) && typeof i?.url === 'string' && /^https:\/\//.test(i.url));
915
- return { hit: hit ? { number: hit.number, url: hit.url, state: typeof hit.state === 'string' ? hit.state.toLowerCase() : 'unknown' } : null };
916
- }
917
-
918
- /**
919
- * Write `text` to a private temp file and run the first argv of `argvsFor(file)`; when it fails
920
- * over a label, run the next one (once).
921
- * @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} text
922
- * @param {(file: string) => string[][]} argvsFor
923
- * @param {(stdout: string) => void} [onOk]
924
- * @returns {Promise<string|null>} null when gh succeeded; else why (`exit n`, never its output).
925
- */
926
- async function ghWithBodyFile(exec, env, text, argvsFor, onOk) {
927
- /** @type {string|null} */
928
- let dir = null;
929
- try {
930
- let res;
931
- try {
932
- const root = currentRunRoot();
933
- await mkdir(root, { recursive: true });
934
- dir = await mkdtemp(path.join(root, 'report-'));
935
- const bodyFile = path.join(dir, 'body.md');
936
- await writeFile(bodyFile, text, { encoding: 'utf8', mode: 0o600 });
937
- const [first, retry] = argvsFor(bodyFile);
938
- res = await ghRun(exec, first, env);
939
- // gh's stderr is matched to decide the retry, never printed
940
- if (res.result !== 'ok' && retry && /label/i.test(res.stderr)) res = await ghRun(exec, retry, env);
941
- } catch {
942
- return 'could not write the report file';
943
- }
944
- if (res.result !== 'ok') return res.code !== null ? `exit ${res.code}` : res.timedOut ? 'timed out' : 'did not finish';
945
- onOk?.(res.stdout);
946
- return null;
947
- } finally {
948
- if (dir !== null) await rm(dir, { recursive: true, force: true }).catch(() => {});
949
- }
950
- }
951
-
952
- /**
953
- * @param {typeof realExec} exec @param {string[]} argv @param {NodeJS.ProcessEnv} env
954
- * @returns {Promise<{result: string, code: number|null, timedOut: boolean, stdout: string, stderr: string}>}
955
- */
956
- async function ghRun(exec, argv, env) {
957
- try {
958
- return await exec(argv, { env, timeoutMs: GH_TIMEOUT_MS });
959
- } catch {
960
- return { result: 'failed', code: null, timedOut: false, stdout: '', stderr: '' };
961
- }
962
- }
963
-
964
826
  /**
965
827
  * The prefilled issue-form link: the form's `details` field holds the whole body (GitHub fills an
966
828
  * issue form's fields from query parameters named by field id).
package/src/cli/plan.mjs CHANGED
@@ -12,14 +12,13 @@
12
12
 
13
13
  import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
- import { loadProjectConfig } from '../config/load.mjs';
15
+ import { loadProjectConfig, slugFor } from '../config/load.mjs';
16
16
  import { ledgerPath } from '../ledger/paths.mjs';
17
17
  import { readAllRows } from '../ledger/write.mjs';
18
18
  import { checkPlan } from '../session/plan-check.mjs';
19
19
  import { parseFlags } from '../state/cli-args.mjs';
20
20
  import { StateError } from '../state/paths.mjs';
21
21
  import { writeSafe } from '../util/redact.mjs';
22
- import { slugFor } from './run.mjs';
23
22
 
24
23
  const USAGE = 'usage: code-forge plan check <plan-file> [--facts <sheet>] [--slug <slug>]\n';
25
24
 
package/src/cli/run.mjs CHANGED
@@ -22,37 +22,18 @@
22
22
  * run record per ticket — no restart).
23
23
  */
24
24
 
25
- import { readdirSync } from 'node:fs';
26
25
  import path from 'node:path';
27
- import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
28
- import { loadSeenInCache } from '../config/refresh.mjs';
29
- import { validateConfig } from '../config/validate.mjs';
26
+ import { reloadFailureText, reloadWorkspace } from '../autopilot/limits.mjs';
27
+ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, slugFor } from '../config/load.mjs';
30
28
  import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
31
29
  import { appendRow, readAllRows } from '../ledger/write.mjs';
32
30
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
33
31
  import { StateError } from '../state/paths.mjs';
34
- import { endRun, processStartTime, readRun, reattachWorker, reloadRun, startRun, stopPinnedWorker } from '../state/run.mjs';
32
+ import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
35
33
  import { writeSafe } from '../util/redact.mjs';
36
34
  import { sweepRoots, tmpBase } from '../util/tmp.mjs';
37
35
  import { launchWorker, repinWorker } from '../worker/loop.mjs';
38
- import { pendingTickets, queueDir } from '../worker/queue.mjs';
39
- import { repoRootOf, WorkerError } from '../worker/ticket.mjs';
40
- import { hasCliOnPath } from './validate.mjs';
41
-
42
- /**
43
- * `project.slug` from config, else the workspace directory name as a ledger slug. The configured
44
- * value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
45
- * (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
46
- * are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
47
- * @param {Record<string, any>} cfg @param {string} workspace
48
- * @returns {string}
49
- */
50
- export function slugFor(cfg, workspace) {
51
- const configured = cfg?.project?.slug;
52
- if (typeof configured === 'string' && configured.length > 0) return configured;
53
- const derived = path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
54
- return derived || 'project';
55
- }
36
+ import { repoRootOf } from '../worker/ticket.mjs';
56
37
 
57
38
  /**
58
39
  * B33: the run's spend so far (estimated USD from its ledger rows, open blocks included) and its
@@ -190,52 +171,29 @@ export async function runRun(args, deps = {}) {
190
171
 
191
172
  /**
192
173
  * `run reload --run <id>`: load + validate the workspace config, pin the queue's pending tickets
193
- * to the old snapshot and store the new one (`reloadRun`). Exit 0 on a reload or no change, 1 on a
194
- * config that does not load or validate, or on a refused key.
174
+ * to the old snapshot and store the new one (`reloadWorkspace` → `reloadRun`). Exit 0 on a reload
175
+ * or no change, 1 on a config that does not load or validate, or on a refused key. B48: while an
176
+ * autopilot grant is active, a change to a limit key is refused (`autopilot-limit`): only the
177
+ * owner changes one, with `code-forge autopilot approve` (the verb never passes its internal allow).
195
178
  * @param {string} runId
196
179
  * @param {{out: (s: string) => void, err: (s: string) => void}} io
197
180
  * @returns {Promise<number>}
198
181
  */
199
182
  async function reloadVerb(runId, { out, err }) {
200
- const { project, workspace, status } = await readRun(runId);
201
- if (status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
202
- const loaded = await loadProjectConfig(workspace);
203
- if (!loaded.ok || !loaded.config) {
204
- // never the parser's message: it can quote a line of the file
205
- if (loaded.error === 'not-found') err(`run reload: no ${DEFAULT_CONFIG_FILENAME} in the workspace — nothing changed\n`);
183
+ const { project } = await readRun(runId);
184
+ const reloaded = await reloadWorkspace({ runId, writeRow: ledgerWriter(project) });
185
+ if (!reloaded.ok) {
186
+ if (reloaded.error === 'not-found') err(`run reload: no ${DEFAULT_CONFIG_FILENAME} in the workspace — nothing changed\n`);
187
+ else if (reloaded.error === 'parse-error') err(`run reload: the config could not be parsed (${DEFAULT_CONFIG_FILENAME}${reloaded.line ? `:${reloaded.line}` : ''}) — nothing changed\n`);
188
+ else if (reloaded.error !== 'invalid') err(`run reload: ${reloadFailureText(reloaded)} — nothing changed\n`);
206
189
  else {
207
- const line = loaded.error === 'parse-error' ? /\(line (\d+), column \d+\)$/.exec(loaded.message ?? '')?.[1] : undefined;
208
- err(`run reload: the config could not be parsed (${DEFAULT_CONFIG_FILENAME}${line ? `:${line}` : ''}) — nothing changed\n`);
190
+ // rule ids and key paths only: a validator message may quote a value
191
+ const errors = reloaded.errors ?? [];
192
+ const lines = errors.map((e) => ` [${e.rule}]${e.path ? ` ${e.path}` : ''}\n`);
193
+ err(`run reload: ${DEFAULT_CONFIG_FILENAME} is invalid (${errors.length} error${errors.length === 1 ? '' : 's'}) — nothing changed; run code-forge validate\n${lines.join('')}`);
209
194
  }
210
195
  return 1;
211
196
  }
212
- const result = validateConfig(loaded.config, { seenInCache: await loadSeenInCache(), hasCliOnPath });
213
- if (!result.valid) {
214
- // rule ids and key paths only: a validator message may quote a value
215
- const lines = result.errors.map((e) => ` [${e.rule}]${typeof e.path === 'string' && e.path.length > 0 ? ` ${e.path}` : ''}\n`);
216
- err(`run reload: ${DEFAULT_CONFIG_FILENAME} is invalid (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}) — nothing changed; run code-forge validate\n${lines.join('')}`);
217
- return 1;
218
- }
219
- /** @type {string | null} */
220
- let repoRoot = null;
221
- try {
222
- repoRoot = await repoRootOf(workspace);
223
- } catch (thrown) {
224
- if (!(thrown instanceof WorkerError && thrown.code === 'no-repo')) throw thrown;
225
- // no repository: there is no review queue to pin
226
- }
227
- const queueRoot = repoRoot;
228
- const readPending = () => {
229
- if (queueRoot === null) return [];
230
- try {
231
- readdirSync(queueDir(queueRoot));
232
- } catch (thrown) {
233
- if (thrown?.code === 'ENOENT') return [];
234
- throw new StateError('queue-unreadable', 'cannot read the review queue; nothing changed');
235
- }
236
- return pendingTickets(queueRoot);
237
- };
238
- const reloaded = await reloadRun({ runId, config: loaded.config, readPending, effectiveSlug: slugFor(loaded.config, workspace), writeRow: ledgerWriter(project) });
239
197
  if (reloaded.changed !== null && reloaded.changed.length === 0) {
240
198
  out(`run ${runId}: no config change\n`);
241
199
  return 0;
package/src/cli/s2.mjs CHANGED
@@ -8,13 +8,12 @@
8
8
 
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
- import { loadProjectConfig } from '../config/load.mjs';
11
+ import { loadProjectConfig, slugFor } from '../config/load.mjs';
12
12
  import { runS2 } from '../session/s2.mjs';
13
13
  import { runRootFor, SessionError } from '../session/spawn.mjs';
14
14
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
15
15
  import { StateError } from '../state/paths.mjs';
16
16
  import { writeSafe } from '../util/redact.mjs';
17
- import { slugFor } from './run.mjs';
18
17
  import { exitCodeFor } from './spawn.mjs';
19
18
 
20
19
  /**
package/src/cli/spawn.mjs CHANGED
@@ -10,12 +10,11 @@
10
10
 
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import path from 'node:path';
13
- import { loadProjectConfig } from '../config/load.mjs';
13
+ import { loadProjectConfig, slugFor } from '../config/load.mjs';
14
14
  import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
15
15
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
16
16
  import { StateError } from '../state/paths.mjs';
17
17
  import { writeSafe } from '../util/redact.mjs';
18
- import { slugFor } from './run.mjs';
19
18
 
20
19
  /**
21
20
  * @param {string} file @param {string} flag
@@ -13,6 +13,11 @@
13
13
  *
14
14
  * Opt-in (default off): `review.allow_open_book_codex: true` lets Codex run those roles with its
15
15
  * read-only argv; every surface then shows {@link OPEN_BOOK_CODEX_WARNING} instead of refusing.
16
+ *
17
+ * B46: the autopilot `delegate` is closed-book too, and the opt-in does NOT open it
18
+ * ({@link NEVER_OPEN_BOOK_ROLES}): the delegate decides FOR the owner while the owner is away, so
19
+ * a delegate that could read files on this machine (a coder-written note, a planted "the owner
20
+ * says yes") would be deciding on material nobody vetted. Codex is refused for it, always.
16
21
  */
17
22
 
18
23
  /** The text every refusal prints: the builder, the spawner, `doctor`, `validate`, `resolve`. */
@@ -36,7 +41,13 @@ export function openBookCodexAllowed(cfg) {
36
41
  export const NO_CLOSED_BOOK_PROVIDER = 'openai';
37
42
 
38
43
  /** Roles that must run with no tools (facts is not one: it has read-only tools by design). */
39
- export const NO_TOOL_ROLES = Object.freeze(['reviewer', 'judge', 's2', 'author']);
44
+ export const NO_TOOL_ROLES = Object.freeze(['reviewer', 'judge', 's2', 'author', 'delegate']);
45
+
46
+ /**
47
+ * No-tools roles that `review.allow_open_book_codex` never opens (B46): the autopilot delegate
48
+ * decides for the owner, so it only ever runs with no tools at all.
49
+ */
50
+ export const NEVER_OPEN_BOOK_ROLES = Object.freeze(['delegate']);
40
51
 
41
52
  /** Levels whose sessions are closed-book: L2 (reviewers), L3 (judge, S2, author). */
42
53
  export const CLOSED_BOOK_LEVELS = Object.freeze(['L2', 'L3']);
@@ -56,6 +67,11 @@ export function isNoToolRole(role) {
56
67
  return NO_TOOL_ROLES.includes(norm(role));
57
68
  }
58
69
 
70
+ /** @param {unknown} role @returns {boolean} true when `role` (any case) is never opened by the opt-in. */
71
+ export function isNeverOpenBookRole(role) {
72
+ return NEVER_OPEN_BOOK_ROLES.includes(norm(role));
73
+ }
74
+
59
75
  /**
60
76
  * @param {unknown} provider @param {unknown} role - compared lowercased and trimmed.
61
77
  * @returns {boolean} true when `provider` cannot run `role` closed-book.
@@ -68,8 +84,9 @@ export function needsClosedBook(provider, role) {
68
84
  * @param {unknown} provider @param {unknown} role @param {boolean} openBookAllowed - the config's
69
85
  * opt-in, read once by the caller with {@link openBookCodexAllowed}.
70
86
  * @returns {boolean} true when the session must be refused: `provider` cannot run `role`
71
- * closed-book and the config has not opted in to open-book Codex.
87
+ * closed-book and the config has not opted in to open-book Codex — or the role is one the
88
+ * opt-in never opens (the delegate, B46).
72
89
  */
73
90
  export function closedBookRefused(provider, role, openBookAllowed) {
74
- return needsClosedBook(provider, role) && openBookAllowed !== true;
91
+ return needsClosedBook(provider, role) && (openBookAllowed !== true || isNeverOpenBookRole(role));
75
92
  }
@@ -133,3 +133,18 @@ export async function loadConfigFile(filePath) {
133
133
  export async function loadProjectConfig(cwd = process.cwd()) {
134
134
  return loadConfigFile(path.join(cwd, DEFAULT_CONFIG_FILENAME));
135
135
  }
136
+
137
+ /**
138
+ * `project.slug` from config, else the workspace directory name as a ledger slug. The configured
139
+ * value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
140
+ * (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
141
+ * are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
142
+ * @param {Record<string, any>} cfg @param {string} workspace
143
+ * @returns {string}
144
+ */
145
+ export function slugFor(cfg, workspace) {
146
+ const configured = cfg?.project?.slug;
147
+ if (typeof configured === 'string' && configured.length > 0) return configured;
148
+ const derived = path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
149
+ return derived || 'project';
150
+ }