@codedology/code-forge 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
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, projectRootFor, 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
|
|
@@ -61,7 +60,9 @@ export async function runSpawn(args, deps = {}) {
|
|
|
61
60
|
writeSafe(stderr, USAGE);
|
|
62
61
|
return 2;
|
|
63
62
|
}
|
|
64
|
-
|
|
63
|
+
// B50: the project root's config and ledger slug, also from a subfolder
|
|
64
|
+
const root = projectRootFor(process.cwd());
|
|
65
|
+
const loaded = await loadProjectConfig(root);
|
|
65
66
|
if (!loaded.ok) {
|
|
66
67
|
writeSafe(stderr, `spawn: ${loaded.message}\n`);
|
|
67
68
|
return 2;
|
|
@@ -82,7 +83,7 @@ export async function runSpawn(args, deps = {}) {
|
|
|
82
83
|
runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
|
|
83
84
|
run: typeof flags.run === 'string' ? flags.run : undefined,
|
|
84
85
|
block: typeof flags.block === 'string' ? flags.block : undefined,
|
|
85
|
-
slug: slugFor(cfg,
|
|
86
|
+
slug: slugFor(cfg, root),
|
|
86
87
|
},
|
|
87
88
|
{ ...deps, stderr },
|
|
88
89
|
);
|
|
@@ -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
|
}
|
package/src/config/load.mjs
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* (`err.pos`) and the source string — never from anything the `yaml` package formats for display.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
18
19
|
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import os from 'node:os';
|
|
19
21
|
import path from 'node:path';
|
|
20
22
|
import { parse as parseYAML } from 'yaml';
|
|
21
23
|
import { migrateConfig } from './migrate.mjs';
|
|
@@ -131,5 +133,115 @@ export async function loadConfigFile(filePath) {
|
|
|
131
133
|
* @returns {Promise<LoadResult>}
|
|
132
134
|
*/
|
|
133
135
|
export async function loadProjectConfig(cwd = process.cwd()) {
|
|
134
|
-
|
|
136
|
+
// B50: always the PROJECT ROOT's config ({@link projectRootFor}; idempotent when `cwd` is the
|
|
137
|
+
// root). A symlinked `.code-forge.yml` (dotfiles) loads like a file; a directory there is a `read-error`.
|
|
138
|
+
return loadConfigFile(path.join(projectRootFor(cwd), DEFAULT_CONFIG_FILENAME));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The nearest ancestor of `start` (itself included) that holds `marker`, or null.
|
|
143
|
+
* @param {string} start @param {string} marker
|
|
144
|
+
* @returns {string | null}
|
|
145
|
+
*/
|
|
146
|
+
function nearestWith(start, marker) {
|
|
147
|
+
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
148
|
+
if (existsSync(path.join(dir, marker))) return dir;
|
|
149
|
+
if (path.dirname(dir) === dir) return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {string} p @returns {boolean} true when `p` is (or links to) a regular file — a symlinked config (dotfiles) counts, a directory never. */
|
|
154
|
+
function isRegularFile(p) {
|
|
155
|
+
try {
|
|
156
|
+
return statSync(p).isFile();
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {string} p @returns {string} `p` realpath'd, or resolved as given when it cannot be. */
|
|
163
|
+
function realOrResolved(p) {
|
|
164
|
+
try {
|
|
165
|
+
return realpathSync(p);
|
|
166
|
+
} catch {
|
|
167
|
+
return path.resolve(p);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The project root a verb run from `start` belongs to (B50). The git top level is found first
|
|
173
|
+
* (the nearest ancestor holding a `.git` directory or worktree file — a filesystem walk, no child
|
|
174
|
+
* `git`); the root is then the nearest folder from `start` up to AND INCLUDING that top level whose
|
|
175
|
+
* `.code-forge.yml` is a regular file (a symlink to one counts; a directory never), else the top
|
|
176
|
+
* level itself — a config above the repository never counts. Outside git, the same search runs
|
|
177
|
+
* from `start` up to the home directory (never past it) when `start` is under it, and looks at
|
|
178
|
+
* `start` alone when it is not (`/tmp/x`, `/opt/ci`: a stray config higher up never counts); found
|
|
179
|
+
* nothing ⇒ the root is `start`. Every walk runs on realpaths; the root comes back in the caller's
|
|
180
|
+
* spelling when an ancestor of the start as given resolves to it ({@link inCallerSpelling}). A verb
|
|
181
|
+
* run from a subfolder thus finds the project's config and ledger slug. Synchronous (no child process).
|
|
182
|
+
* @param {string} [start] - defaults to `process.cwd()`.
|
|
183
|
+
* @returns {string} absolute.
|
|
184
|
+
*/
|
|
185
|
+
export function projectRootFor(start = process.cwd()) {
|
|
186
|
+
return inCallerSpelling(start, resolvedRootFor(realOrResolved(start)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The root on realpaths: the git walk, the config walk and the HOME check all run on the resolved
|
|
191
|
+
* start, so a symlink into a repository finds its `.git`.
|
|
192
|
+
* @param {string} from - the realpath of the start.
|
|
193
|
+
* @returns {string} a realpath.
|
|
194
|
+
*/
|
|
195
|
+
function resolvedRootFor(from) {
|
|
196
|
+
const home = realOrResolved(os.homedir());
|
|
197
|
+
// a `.git` at HOME itself (a dotfiles repository) never makes HOME the project: outside git
|
|
198
|
+
const gitRoot = nearestWith(from, '.git');
|
|
199
|
+
if (gitRoot !== null && gitRoot !== home) return nearestConfigUpTo(from, gitRoot) ?? gitRoot;
|
|
200
|
+
const underHome = from === home || from.startsWith(`${home}${path.sep}`);
|
|
201
|
+
if (!underHome) return from; // the start alone: its own config (if any) or not, the root is the start
|
|
202
|
+
return nearestConfigUpTo(from, home) ?? from;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The root in the caller's spelling when it can be: the ancestor of `path.resolve(start)` (itself
|
|
207
|
+
* included) whose realpath IS the resolved root (`~/work/app` → `/data/app-v2` keeps `app`, and its
|
|
208
|
+
* slug); none ⇒ the realpath itself.
|
|
209
|
+
* @param {string} start @param {string} realRoot
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function inCallerSpelling(start, realRoot) {
|
|
213
|
+
for (let dir = path.resolve(start); ; dir = path.dirname(dir)) {
|
|
214
|
+
if (realOrResolved(dir) === realRoot) return dir;
|
|
215
|
+
if (path.dirname(dir) === dir) return realRoot;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The nearest folder from `from` up to and including `stop` whose `.code-forge.yml` is a regular file.
|
|
221
|
+
* @param {string} from @param {string} stop - an ancestor of `from` (or `from` itself).
|
|
222
|
+
* @returns {string | null}
|
|
223
|
+
*/
|
|
224
|
+
function nearestConfigUpTo(from, stop) {
|
|
225
|
+
for (let dir = from; ; dir = path.dirname(dir)) {
|
|
226
|
+
if (isRegularFile(path.join(dir, DEFAULT_CONFIG_FILENAME))) return dir;
|
|
227
|
+
if (dir === stop || path.dirname(dir) === dir) return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `project.slug` from config, else the PROJECT ROOT's directory name ({@link projectRootFor} of
|
|
233
|
+
* `workspace`, B50: a subfolder never names the ledger) as a ledger slug. The configured
|
|
234
|
+
* value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
|
|
235
|
+
* (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
|
|
236
|
+
* are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
|
|
237
|
+
* `cfg` MUST be the config loaded from `projectRootFor(workspace)` (never the cwd's), or a
|
|
238
|
+
* configured `project.slug` is lost from a subfolder and the ledger splits.
|
|
239
|
+
* @param {Record<string, any>} cfg @param {string} workspace
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
export function slugFor(cfg, workspace) {
|
|
243
|
+
const configured = cfg?.project?.slug;
|
|
244
|
+
if (typeof configured === 'string' && configured.length > 0) return configured;
|
|
245
|
+
const derived = path.basename(projectRootFor(workspace)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
246
|
+
return derived || 'project';
|
|
135
247
|
}
|
package/src/doctor/probes.mjs
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Doctor rows that run the provider CLIs (plan §2.3, §0.6; block B13b):
|
|
3
3
|
* - per CLI: present + version + `--help` flag probe (B4 `probeHelpText`; a missing flag FAILs);
|
|
4
4
|
* - one real call per role builder (coder, reviewer, s2 with the compiled S2 schema, facts) through
|
|
5
|
-
* B9a `spawnSession` — an HTTP 402 is `ping=skipped(402)` WARN, never FAIL (§0.6.1);
|
|
5
|
+
* B9a `spawnSession` — an HTTP 402 is `ping=skipped(402)` WARN, never FAIL (§0.6.1); when the
|
|
6
|
+
* config has an `autopilot` section (B46), one `delegate` ping per delegate level (L2, L3) with
|
|
7
|
+
* the delegate schema — an openai level FAILs unrun whatever `review.allow_open_book_codex` says;
|
|
6
8
|
* - the isolation probe (O4, §0.6.5): the closed-book reviewer argv, run with cwd = the project
|
|
7
9
|
* itself (the worst case), asked to echo the first line of its `CLAUDE.md`/`AGENTS.md` — the
|
|
8
10
|
* line appearing in its answer FAILs; the CLI rejecting the closed-book flag combination FAILs;
|
|
@@ -33,6 +35,8 @@ import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
|
33
35
|
import { commandOnPath } from '../install/detect.mjs';
|
|
34
36
|
import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
|
|
35
37
|
import { S2_SCHEMA } from '../session/s2.mjs';
|
|
38
|
+
import { DELEGATE_SCHEMA } from '../autopilot/delegate.mjs';
|
|
39
|
+
import { DELEGATE_LEVELS } from '../autopilot/scopes.mjs';
|
|
36
40
|
import { runsDir } from '../state/paths.mjs';
|
|
37
41
|
import { exec } from '../util/exec.mjs';
|
|
38
42
|
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
@@ -48,6 +52,23 @@ export const PING_ROLES = Object.freeze([
|
|
|
48
52
|
{ role: 's2', level: 'L3' },
|
|
49
53
|
]);
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* B46: the delegate pings, one per level a grant may name — run only when the config has an
|
|
57
|
+
* `autopilot` section (a project that never uses autopilot pays for no extra call).
|
|
58
|
+
* @param {Record<string, any>} cfg @returns {Array<{role: string, level: string, id: string}>}
|
|
59
|
+
*/
|
|
60
|
+
export function delegatePings(cfg) {
|
|
61
|
+
const on = cfg?.autopilot !== null && typeof cfg?.autopilot === 'object';
|
|
62
|
+
return on ? DELEGATE_LEVELS.map((level) => ({ role: 'delegate', level, id: `ping.delegate.${level}` })) : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What each schema'd ping is told to answer, so a working CLI matches the schema. */
|
|
66
|
+
const PING_ANSWER = Object.freeze({
|
|
67
|
+
s2: 'Answer with decision "proceed", confidence 1, reason "ping", overrule false, ask_human false, human_question null.\n',
|
|
68
|
+
delegate: 'Answer with decision "proceed", within_scope true, confidence 1, reason "ping", escalate false.\n',
|
|
69
|
+
});
|
|
70
|
+
const PING_SCHEMA = Object.freeze({ s2: S2_SCHEMA, delegate: DELEGATE_SCHEMA });
|
|
71
|
+
|
|
51
72
|
const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
52
73
|
const PROBE_TIMEOUT_MS = 180000;
|
|
53
74
|
const PROBE_BUDGET_USD = 0.5;
|
|
@@ -196,28 +217,30 @@ function levelName(cfg, level) {
|
|
|
196
217
|
*/
|
|
197
218
|
export async function pingRoles(ctx, missingProviders) {
|
|
198
219
|
const rows = [];
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
const pings = [...PING_ROLES.map((p) => ({ ...p, id: `ping.${p.role}` })), ...delegatePings(ctx.cfg)];
|
|
221
|
+
for (const { role, level, id } of pings) {
|
|
222
|
+
const label = role === 'delegate' ? `ping delegate ${level} (${levelName(ctx.cfg, level)})` : `ping ${role} (${levelName(ctx.cfg, level)})`;
|
|
201
223
|
const provider = ctx.cfg.levels?.[level]?.provider ?? ctx.cfg.provider;
|
|
202
224
|
if (missingProviders.has(provider)) {
|
|
203
|
-
rows.push(row(
|
|
225
|
+
rows.push(row(id, 'FAIL', label, 'ping=skipped(cli-missing)'));
|
|
204
226
|
continue;
|
|
205
227
|
}
|
|
206
228
|
if (closedBookRefused(resolvedProvider(ctx.cfg, /** @type {'L0'|'L1'|'L2'|'L3'} */ (level)), role, openBookCodexAllowed(ctx.cfg))) {
|
|
207
|
-
rows.push(row(
|
|
229
|
+
rows.push(row(id, 'FAIL', label, CODEX_CLOSED_BOOK_REFUSAL)); // B32/B46: the spawner would refuse it
|
|
208
230
|
continue;
|
|
209
231
|
}
|
|
232
|
+
const schemaRole = /** @type {'s2' | 'delegate' | null} */ (role === 's2' || role === 'delegate' ? role : null);
|
|
210
233
|
const res = await session(ctx, {
|
|
211
234
|
role,
|
|
212
235
|
level,
|
|
213
|
-
prompt:
|
|
214
|
-
...(
|
|
236
|
+
prompt: schemaRole ? `${PING_TEXT}${PING_ANSWER[schemaRole]}` : PING_TEXT,
|
|
237
|
+
...(schemaRole ? { schema: PING_SCHEMA[schemaRole] } : {}),
|
|
215
238
|
...(role === 'coder' ? { cwd: await tempRepo(ctx) } : {}),
|
|
216
239
|
});
|
|
217
|
-
if (skipped402(res)) rows.push(row(
|
|
218
|
-
else if (res.status === 'ok') rows.push(row(
|
|
219
|
-
else if (res.status === 'invalid-output') rows.push(row(
|
|
220
|
-
else rows.push(row(
|
|
240
|
+
if (skipped402(res)) rows.push(row(id, 'WARN', label, 'ping=skipped(402)'));
|
|
241
|
+
else if (res.status === 'ok') rows.push(row(id, 'OK', label, 'ping=ok'));
|
|
242
|
+
else if (res.status === 'invalid-output') rows.push(row(id, 'WARN', label, 'ping=answered (schema mismatch)'));
|
|
243
|
+
else rows.push(row(id, 'FAIL', label, `ping=${res.status}(${res.reason ?? 'unknown'})`));
|
|
221
244
|
}
|
|
222
245
|
return rows;
|
|
223
246
|
}
|
|
@@ -57,7 +57,7 @@ export const CLI = 'claude';
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* @typedef {object} ClaudeBuildParams
|
|
60
|
-
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
60
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
|
|
61
61
|
* @property {string} model - the resolved model id (already picked by `resolveLevel`, B1).
|
|
62
62
|
* @property {string} [effort] - one of `PROVIDER_EFFORTS.anthropic` (`../efforts.mjs`) when given.
|
|
63
63
|
* @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
|
|
@@ -86,7 +86,7 @@ export const CLI = 'claude';
|
|
|
86
86
|
/**
|
|
87
87
|
* @typedef {object} BuiltClosedBook
|
|
88
88
|
* @property {"claude"} cli
|
|
89
|
-
* @property {"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
89
|
+
* @property {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
|
|
90
90
|
* @property {string[]} argv - no prompt positional.
|
|
91
91
|
* @property {string} cwd
|
|
92
92
|
* @property {string} stdinFile - the packet file the spawner pipes to stdin.
|
|
@@ -190,7 +190,7 @@ function buildClosedBookArgv(params) {
|
|
|
190
190
|
assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
|
|
191
191
|
// The dispatch in `buildClaudeArgv` never reaches this branch with role 'coder' — a runtime
|
|
192
192
|
// invariant the parameter type alone cannot prove, so the narrowing is explicit.
|
|
193
|
-
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
193
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
|
|
194
194
|
const isFacts = role === 'facts';
|
|
195
195
|
if (isFacts && !hasAnyRenderedRule(renderedForbidden)) {
|
|
196
196
|
throw new Error('buildClaudeArgv: facts role requires a non-empty forbidden-list render (got 0 usable rules)');
|
|
@@ -65,7 +65,7 @@ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } f
|
|
|
65
65
|
import { hasAnyRenderedRule } from '../render-rules.mjs';
|
|
66
66
|
import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
|
|
67
67
|
import { assertBaseParams } from './validate-params.mjs';
|
|
68
|
-
import { CODEX_CLOSED_BOOK_REFUSAL, isNoToolRole } from '../../config/closed-book.mjs';
|
|
68
|
+
import { CODEX_CLOSED_BOOK_REFUSAL, isNeverOpenBookRole, isNoToolRole } from '../../config/closed-book.mjs';
|
|
69
69
|
|
|
70
70
|
export const CLI = 'codex';
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
|
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* @typedef {object} CodexBuildParams
|
|
107
|
-
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
107
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
|
|
108
108
|
* @property {string} model
|
|
109
109
|
* @property {string} [effort] - one of {@link VALID_EFFORTS} when given.
|
|
110
110
|
* @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
|
|
@@ -217,14 +217,14 @@ function buildCoderArgv(params) {
|
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
219
|
* @param {CodexBuildParams} params
|
|
220
|
-
* @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string, outPath: string, stdinFile: string}}
|
|
220
|
+
* @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string, outPath: string, stdinFile: string}}
|
|
221
221
|
*/
|
|
222
222
|
function buildClosedBookArgv(params) {
|
|
223
223
|
const { model, effort, promptPath, cwd, schemaPath } = params;
|
|
224
224
|
assertValidEffort(effort);
|
|
225
225
|
// See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch with
|
|
226
226
|
// role 'coder'; a no-tools role reaches it only with `allowOpenBook: true` (B32).
|
|
227
|
-
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
227
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
|
|
228
228
|
const outPath = effectiveOutPath(params.outPath, role);
|
|
229
229
|
const argv = ['codex', 'exec', '-m', model];
|
|
230
230
|
if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
|
|
@@ -248,9 +248,10 @@ function buildClosedBookArgv(params) {
|
|
|
248
248
|
*/
|
|
249
249
|
export function buildCodexArgv(params) {
|
|
250
250
|
assertBaseParams('buildCodexArgv', params);
|
|
251
|
-
if (isNoToolRole(params.role) && params.allowOpenBook !== true) {
|
|
251
|
+
if (isNoToolRole(params.role) && (params.allowOpenBook !== true || isNeverOpenBookRole(params.role))) {
|
|
252
252
|
// B32: no Codex flag removes the shell, so a no-tools role is built only when the caller
|
|
253
253
|
// passes `allowOpenBook: true` (the config's `review.allow_open_book_codex` opt-in).
|
|
254
|
+
// B46: the autopilot delegate is never built on Codex, opt-in or not.
|
|
254
255
|
throw Object.assign(new Error(CODEX_CLOSED_BOOK_REFUSAL), { code: 'closed-book' });
|
|
255
256
|
}
|
|
256
257
|
return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
|
|
@@ -56,7 +56,7 @@ export const GROK_ALL_BUILTIN_TOOLS = Object.freeze(['Bash', 'Read', 'Write', 'E
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* @typedef {object} GrokBuildParams
|
|
59
|
-
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
59
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
|
|
60
60
|
* @property {string} model
|
|
61
61
|
* @property {string} [effort] - a non-empty string when given.
|
|
62
62
|
* @property {string} promptPath - `--prompt-file` value (brief for coder, packet for closed-book).
|
|
@@ -100,14 +100,14 @@ function buildCoderArgv(params) {
|
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
102
|
* @param {GrokBuildParams} params
|
|
103
|
-
* @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string}}
|
|
103
|
+
* @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string}}
|
|
104
104
|
*/
|
|
105
105
|
function buildClosedBookArgv(params) {
|
|
106
106
|
const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
|
|
107
107
|
assertValidEffort(effort);
|
|
108
108
|
// See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
|
|
109
109
|
// with role 'coder', but that is a runtime invariant, not provable from the parameter type.
|
|
110
|
-
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
110
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
|
|
111
111
|
const argv = ['grok', '--prompt-file', promptPath, '-m', model];
|
|
112
112
|
if (effort) argv.push('--reasoning-effort', effort);
|
|
113
113
|
if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The six roles the plan's engine adapters section (§5.2) declares.
|
|
11
|
-
* @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts">}
|
|
10
|
+
* The six roles the plan's engine adapters section (§5.2) declares, plus the autopilot `delegate` (B46, closed-book).
|
|
11
|
+
* @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate">}
|
|
12
12
|
*/
|
|
13
|
-
export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts']));
|
|
13
|
+
export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']));
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* @param {string} builderName - for the thrown message, e.g. `"buildClaudeArgv"`.
|
package/src/ledger/report.mjs
CHANGED
package/src/review/fixloop.mjs
CHANGED
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
* new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
|
|
34
34
|
* `review.approved {file, content_hash, round}` (the worker signs every row it writes).
|
|
35
35
|
*
|
|
36
|
+
* Autopilot extra round (B47): `deps.extraRounds` (from {@link extraRoundsFor}: one per signed
|
|
37
|
+
* `autopilot.extra_round` row for the file, one per grant) raises the file's cap by that many
|
|
38
|
+
* rounds — the cap checks here and rule 6 in `escalationAfterAttempt` both use the raised cap. A
|
|
39
|
+
* file already stopped at `review_cap` is reopened for its extra round by
|
|
40
|
+
* {@link reopenForExtraRound} (the worker calls it before the round).
|
|
41
|
+
*
|
|
36
42
|
* Over budget (B12a follow-up, decided here): a recheck packet over `review.budgets.full_in`
|
|
37
43
|
* falls to `contextMode: 'minimal'` (± `min_context_lines` around the SAME fix hunks) exactly as
|
|
38
44
|
* `assemblePacket` does for any packet. The budget is a hard ceiling on what one session reads;
|
|
@@ -54,6 +60,8 @@ import { randomBytes } from 'node:crypto';
|
|
|
54
60
|
import path from 'node:path';
|
|
55
61
|
import { escalationAfterAttempt, heavyRound, reviewRoundStalled } from '../decide/escalation.mjs';
|
|
56
62
|
import { exec } from '../util/exec.mjs';
|
|
63
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
64
|
+
import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
57
65
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
58
66
|
import { parseDiff } from './context.mjs';
|
|
59
67
|
import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
|
|
@@ -103,6 +111,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
103
111
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
104
112
|
* @property {string | null} [base] - the block base (only for `recheck_scope: file`).
|
|
105
113
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
114
|
+
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
106
115
|
*/
|
|
107
116
|
|
|
108
117
|
/** @param {LoopDeps} deps @param {Record<string, any>} row */
|
|
@@ -114,13 +123,68 @@ async function note(deps, row) {
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
/** @param {
|
|
118
|
-
|
|
126
|
+
/** @param {unknown} v @param {number} d @returns {number} `v` when a non-negative integer, else `d`. */
|
|
127
|
+
const int = (v, d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The file's round cap: `review.max_rounds_per_file` (default 4) plus the autopilot extra rounds.
|
|
131
|
+
* @param {Record<string, any> | undefined} cfg @param {number} [extraRounds]
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
export function roundCap(cfg, extraRounds = 0) {
|
|
135
|
+
return int(cfg?.review?.max_rounds_per_file, 4) + int(extraRounds, 0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* B47: the extra rounds autopilot granted the file — the number of distinct grants with an
|
|
140
|
+
* `autopilot.extra_round` row of this run for this block and file that holds up as the gate's
|
|
141
|
+
* waiver check does: its MAC verifies, its grant covered `round:extra` at its `ts`
|
|
142
|
+
* (`grantCoverProblem`) and its `decision_id` names the delegate's acted decision for that block
|
|
143
|
+
* and file (`decisionProblem`). A row that fails any check, or whose check throws, counts for nothing.
|
|
144
|
+
* @param {Array<Record<string, any>>} rows @param {{runId: string, block: string, file: string, key: Buffer}} where
|
|
145
|
+
* @returns {number}
|
|
146
|
+
*/
|
|
147
|
+
export function extraRoundsFor(rows, { runId, block, file, key }) {
|
|
148
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
149
|
+
const grants = new Set();
|
|
150
|
+
for (const r of runRows) {
|
|
151
|
+
if (r?.event !== 'autopilot.extra_round' || r.block !== block || r.file !== file || typeof r.grant_id !== 'string') continue;
|
|
152
|
+
try {
|
|
153
|
+
if (!verifyRow(r, key).ok) continue;
|
|
154
|
+
const at = typeof r.ts === 'string' ? Date.parse(r.ts) : Number.NaN;
|
|
155
|
+
if (!Number.isFinite(at)) continue;
|
|
156
|
+
if (grantCoverProblem(runRows, { grantId: r.grant_id, scope: 'round:extra', at, key, endRows: rows }) !== null) continue;
|
|
157
|
+
if (decisionProblem(runRows, { decisionId: r.decision_id, grantId: r.grant_id, scope: 'round:extra', subject: { block, file }, decision: 'allow', at, key }) !== null) continue;
|
|
158
|
+
grants.add(r.grant_id);
|
|
159
|
+
} catch {
|
|
160
|
+
// a row the checks cannot read gives no round (fail closed)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return grants.size;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
|
|
168
|
+
* back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
|
|
169
|
+
* is a recheck. Any other state is left as it is.
|
|
170
|
+
* @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
|
|
171
|
+
* @returns {boolean} whether the state was reopened.
|
|
172
|
+
*/
|
|
173
|
+
export function reopenForExtraRound(state, extraRounds, cfg) {
|
|
174
|
+
if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
|
|
175
|
+
if (state.round >= roundCap(cfg, extraRounds)) return false;
|
|
176
|
+
state.status = 'open';
|
|
177
|
+
state.next = { action: 'fix', level: state.level, trigger: 'autopilot_extra_round' };
|
|
178
|
+
state.pending_kind = null;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {Record<string, any> | undefined} cfg @param {number} [extraRounds] */
|
|
183
|
+
function settings(cfg, extraRounds = 0) {
|
|
119
184
|
const r = cfg?.review ?? {};
|
|
120
185
|
const e = cfg?.escalation ?? {};
|
|
121
|
-
const int = (/** @type {unknown} */ v, /** @type {number} */ d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
|
|
122
186
|
return {
|
|
123
|
-
maxRounds:
|
|
187
|
+
maxRounds: roundCap(cfg, extraRounds),
|
|
124
188
|
scope: r.recheck_scope === 'file' ? 'file' : 'fix_hunks',
|
|
125
189
|
late: r.late_findings === 'block' ? 'block' : 'sweep',
|
|
126
190
|
perLevel: int(e.review_rounds_per_level, 2),
|
|
@@ -332,7 +396,7 @@ async function stopBeforeRound(state, deps, kind, file, reason) {
|
|
|
332
396
|
* @returns {Promise<FileState>}
|
|
333
397
|
*/
|
|
334
398
|
export async function runRound(state, deps, opts = {}) {
|
|
335
|
-
const s = settings(deps.cfg);
|
|
399
|
+
const s = settings(deps.cfg, deps.extraRounds);
|
|
336
400
|
const kind = opts.kind ?? (state.round === 0 ? 'full' : 'recheck');
|
|
337
401
|
const current = readCurrent(deps.repoRoot, state.file);
|
|
338
402
|
const openBefore = state.open.length;
|