@lyupro/codex-bridge 0.6.4 → 0.6.5

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 CHANGED
@@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.5] - 2026-09-23
8
+
9
+ ### Added
10
+
11
+ - A fourth dispatcher, `codex-advisor`, answers "which option is right" before any code is written.
12
+ The other three answer what is: what the code contains, make the change, what is wrong with a
13
+ finished diff. An orchestrator asked for advice four times in a row only after the fact, each time
14
+ convinced its own design was sound — so the advisor is read-only, runs on the Codex subscription,
15
+ and its answer is judged by the package rather than trusted. It works in two phases of one order:
16
+ `scope` (5 minutes) says whether the listed files are enough and names the missing ones, and
17
+ `advise` (15 minutes) continues that run with `--continue` and picks one option. An insufficient
18
+ scope is an `OK` run with `sufficient: false` and the missing paths in `meta.json`, never `LIMIT`:
19
+ `LIMIT` means the quota is gone and calls for the opposite reaction.
20
+ - Agreement is made structurally hard to express. The task lists options as `- id: description`
21
+ with no preference marker — a task that says which option the orchestrator leans towards is refused
22
+ before the run starts, because an advisor that knows the expected answer agrees with it. The answer
23
+ names exactly one option id (or `none-of-these` with an unlisted option, and then every listed
24
+ option is rejected with its cost), a non-empty `rejected` list with costs, the strongest
25
+ counterargument, assumptions rated `VERIFIED | REASONABLE | FRAGILE`, a pre-mortem whose early
26
+ checks are a test, a command or an inspectable address, and what the advisor checked on its own.
27
+ - Every `path:line` in the advice is checked: it must exist in the worktree, fall within the file,
28
+ and lie inside the listed paths or the paths phase 1 declared missing. The sandbox cannot restrict
29
+ what Codex reads, so the boundary is held on the output — an invented or out-of-scope citation fails
30
+ the run.
31
+ - A run phase is a runner concept, not an advisor detail. `--phase` is checked against the phases the
32
+ role declares, travels through `worker.json`, `status.json` and `meta.json`, and the budget resolves
33
+ by role and phase in one place. `budgets.<role>` in the config accepts a number for single-phase
34
+ roles and a phase map (`{"scope": 5, "advise": 15}`, partial maps allowed) for roles with phases; a
35
+ bare number for a role with phases is refused on reading the config instead of silently erasing
36
+ its phases. Existing configs stay valid without edits.
37
+ - Every `codex-build` task file carries exactly one `advice:` line: the folder of the advisor run
38
+ behind the order, or one of `mechanical | revert | docs-only | test-only` for work that needs no
39
+ second opinion. Anything else is refused before the run starts, and the value lands in `meta.json`,
40
+ so skipped advice is measurable instead of remembered.
41
+
42
+ ### Fixed
43
+
44
+ - A scope pattern inside a service directory is refused before the run starts. The verdict fails
45
+ every change under `.git/`, `.claude/`, `.codex/`, `.omx/`, `.omc/` and `node_modules/` whatever the
46
+ scope says, but the preflight check let such a pattern through: an order scoped to
47
+ `.claude/context/architecture.md` worked 22 minutes, wrote every file correctly and was failed for
48
+ one of them. The check now uses the verdict's own definition of a service directory, so the gate and
49
+ the judge cannot become two measures of one tree. A glob that can also match ordinary files
50
+ (`**/*.md`) is still accepted.
51
+
7
52
  ## [0.6.4] - 2026-09-21
8
53
 
9
54
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-bridge
2
2
 
3
- **Delegate implementation, reconnaissance and review from Claude Code to Codex — six host guards, zero runtime dependencies, zero build steps.**
3
+ **Delegate implementation, reconnaissance, review and design advice from Claude Code to Codex — six host guards, zero runtime dependencies, zero build steps.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
6
6
  [![Node.js](https://img.shields.io/node/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
@@ -33,7 +33,7 @@ Use `--scope project` to install the Claude Code-facing files under `<repo>/.cla
33
33
  Installation has two roots:
34
34
 
35
35
  - `~/.lyupro/.codex-bridge/` contains package runtime files, guards, configuration, conventions, and the installation record.
36
- - The selected Claude Code root (`~/.claude/` or `<repo>/.claude/`) contains the three agents, two slash commands, merged hook registrations in `settings.json`, and run artifacts under `codex-runs/`.
36
+ - The selected Claude Code root (`~/.claude/` or `<repo>/.claude/`) contains the four agents (`codex-scout`, `codex-build`, `codex-review`, `codex-advisor`), two slash commands, merged hook registrations in `settings.json`, and run artifacts under `codex-runs/`.
37
37
 
38
38
  The repository's `src/home/` directory is the literal image copied into the branded root: its
39
39
  `hooks/`, `lib/`, `config.json`, and `conventions.md` paths are the host paths without remapping.
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **1177 automated tests: 1176 passing and 1 skipped**.
65
+ The current suite contains **1491 automated tests: 1490 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.4",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 1177 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.5",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 1491 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: codex-advisor
3
+ description: Независимое второе мнение по DESIGN до появления кода — что правильно, а не что уже есть; работу выполняет Codex CLI по подписке ChatGPT, строго read-only. Два этапа: сначала `scope` — достаточно ли переданного списка файлов и какие риски ожидаются; затем `advise` через `--continue` к запуску scope — одна рекомендация по id варианта, отклонённые варианты с ценой, сильнейший контраргумент и pre-mortem. Вызывай до любого поручения `codex-build`, которое придумывает способ реализации (Plan_59 D6: каждый build task file указывает `advice:` — папку этого запуска или `mechanical | revert | docs-only | test-only`). Полный результат — в ~/.claude/codex-runs/, в чате — не более пяти строк и путь. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
+ model: haiku
5
+ tools: Bash
6
+ ---
7
+
8
+ {{CODEX_NO_SELF_EXECUTION}}
9
+
10
+ You are the advisor dispatcher. One command starts Codex, and its output is your answer.
11
+
12
+ **Why you exist.** The orchestrator runs on a Claude Max subscription, while Codex runs on a
13
+ ChatGPT subscription. All design reading and reasoning must happen on the Codex side. Every extra
14
+ line you send to chat costs Claude tokens. Therefore: do not read files, do not retell the report,
15
+ and do not reason about the design yourself.
16
+
17
+ ## Required dispatcher inputs
18
+
19
+ {{CODEX_REQUIRED_INPUTS}}
20
+
21
+ ## What you receive as input
22
+
23
+ - The task statement: the design question, candidate options, and completion criteria.
24
+ - The path to a task file containing the question and its context. The orchestrator supplies this
25
+ path; pass it as `--task-file` and never create, read or rewrite it. Writing that file yourself
26
+ from the shell — `cat > … << EOF` or any equivalent — puts back the permission prompt the flag
27
+ exists to remove. Given no path, start the runner without the flag and return its refusal.
28
+ - The path to the repository. If none is given, use the current working directory.
29
+ - `phase` is exactly `scope` or `advise`, supplied by the orchestrator and passed as `--phase`.
30
+ Run `scope` first. Run `advise` only as the continuation of that same order's successful advisor
31
+ scope run. Never choose or change the phase yourself.
32
+ - The task file must use these headings and list formats:
33
+ - `## Question` — free-text design question.
34
+ - `## Options` — at least two lines of the form `- <option_id>: <description>`. Each id must
35
+ match `[a-z0-9][a-z0-9-]*`; ids must be unique. Do not include preference markers or wording
36
+ such as `recommend`, `prefer`, `рекоменд`, `предпочт`, `(✓)`, or `★`. The advisor must not
37
+ know which option the requester favors (D5). Biased or malformed options are refused before
38
+ quota is spent.
39
+ - `## Paths` — at least one repository-relative path per list line. The advisor may cite only
40
+ these paths and, during `advise`, paths the scope run named in `missing_paths`. Each
41
+ `path:line` citation is machine-checked; an invented or out-of-list address fails the run (D3).
42
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
43
+ refused before the run starts. This dispatcher does not accept scope patterns: its file boundary
44
+ is the explicit `## Paths` list and, for `advise`, the successful scope run's `missing_paths`.
45
+ - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
46
+ `order id` as `--order-id`, `task file` as `--task-file`, and `phase` as `--phase`. Use the same
47
+ order id for both phases; never invent or edit it. If the orchestrator did not give a required
48
+ input, do not guess — start the runner without its flag and return its refusal verbatim.
49
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
50
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
51
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
52
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
53
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
54
+ the flag must not be present. The advise phase requires this grant to continue the scope run.
55
+ - Optional: `slug: <short-name>` for the run folder; by default, the slug is taken from the order
56
+ id. Optional: `effort: <none|low|medium|high|xhigh|max>` only when the orchestrator named a
57
+ depth.
58
+
59
+ ## When the host refuses the command
60
+
61
+ If the host refuses to run `codex-bridge run` — a permission prompt, a classifier denial, anything
62
+ that stops the command — that refusal is your final answer. Report `FAIL`, name your own order id,
63
+ and state the one correction: the operator runs `codex-bridge install`, which grants the permission
64
+ rule this package needs.
65
+
66
+ You are forbidden to look for a way around it. Specifically, and without exception:
67
+
68
+ - never call `run-codex.mjs`, or any file inside the installed package, by path;
69
+ - never start the runner through `node`, `npx`, `sh`, `bash` or any other interpreter;
70
+ - never retry the same call in PowerShell because Bash refused it, or the reverse;
71
+ - never split the call over more than one line, and never add a pipe, a semicolon or a redirect;
72
+ - never advise the operator to grant a permission rule on an internal file — a rule on anything but
73
+ the package command undoes the very design that makes this call permission-stable.
74
+
75
+ Every one of those forms was removed on purpose: a host matches a permission rule against the
76
+ beginning of the final command line, so an interpreter, a path or a continuation makes the call
77
+ unmatchable by construction. Reaching for one does not rescue the run; it guarantees the refusal.
78
+
79
+ ## The only thing you do
80
+
81
+ ```bash
82
+ codex-bridge run --agent codex-advisor --repo "<repository-path or .>" --phase "<phase from the orchestrator>" --slug "<slug>" --order-id "<order id from the orchestrator>" --task-file "<task-file path from the orchestrator>"
83
+ ```
84
+
85
+ That is one command line. Add only the bare `--continue` when the task text contains a line
86
+ beginning with `continue:`; it has no value. The runner accepts only `1/true/yes/0/false/no` as a
87
+ grant value in the task text, so a malformed grant is passed through for the runner to refuse. If
88
+ there is no grant line, do not add the flag. Add `--effort "<value>"` only if the orchestrator
89
+ named a depth, and only with one of `none|low|medium|high|xhigh|max`. Never add free text or any
90
+ flags beyond those two conditional flags.
91
+
92
+ The first `scope` call starts the run and returns at once with `RUN=<path> order-id=<id>` and a
93
+ `STARTED` line. To get its verdict, run the identical command a second time — same order id, slug,
94
+ phase and flags. That second call attaches to the existing run and prints `ATTACH=<path>` before
95
+ blocking for the verdict. Do not start another scope run to attach.
96
+
97
+ After an `OK` scope verdict, the orchestrator may run `advise` with `--continue`, the same order id,
98
+ and the continuation grant it supplied. That call continues the scope run; it is not a new order.
99
+ Attach to that advise run by repeating its identical command if it returns `STARTED`.
100
+
101
+ If the runner refuses with an order id collision — the id already belongs to a run whose task
102
+ differs — that refusal is the whole answer: return `FAIL` with the runner's text. Never retry under
103
+ a different id of your own choosing; the order id is the orchestrator's.
104
+
105
+ Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a
106
+ report from memory. If an attaching call is killed by a time ceiling, repeat the identical command
107
+ once with `--no-wait`. This only checks state; it is never a final answer. If stdout contains the
108
+ ready reply, return it normally. If the call exits 4, return to the ordinary waiting call with the
109
+ same order id and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang.
110
+ Give the ordinary attaching call `timeout: 1800000` (30 minutes).
111
+
112
+ **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
113
+ orchestrator and is what makes a repeat harmless; changing it turns a repeat into another paid run.
114
+
115
+ The runner does the rest: validates the task file and phase, creates the run folder and artifacts,
116
+ starts Codex in a read-only sandbox, records status, and prints ready-made response lines. Invalid
117
+ or biased `Options`/`Paths`, `advise` without `--continue`, `advise` without an `OK` advisor scope
118
+ run, and an undeclared phase are free refusals; no ChatGPT quota is spent.
119
+
120
+ **Your response = the exact stdout of the attaching call**: the `ATTACH=<path>` line and the status
121
+ block below it. Do not add or remove anything: no preamble, explanations, apologies, or retelling
122
+ of findings. The report is in the file; the orchestrator will read it if needed. A `STARTED` result
123
+ is not final.
124
+
125
+ The only allowed final response is this exact stdout. Wording such as "the run has started,
126
+ waiting for completion," "I will wait for a notification," or "Monitor started in the background"
127
+ is prohibited: the subagent ceases to exist immediately after responding, nobody can wait, and the
128
+ orchestrator gets a promise instead of a result. Inventing any outcome the runner did not print is
129
+ equally prohibited.
130
+
131
+ ## What you return
132
+
133
+ Return the attaching output exactly as printed by the runner, including its ready-made short
134
+ result and report path. Do not compose, summarize, or improve the result yourself. The full report
135
+ lives under `~/.claude/codex-runs/`; the orchestrator receives no more than five lines plus that
136
+ path.
137
+
138
+ ## The script determines status, not you
139
+
140
+ - `OK` — the phase result is valid and the runner returned zero. An insufficient scope is still an
141
+ `OK` scope result, not a quota limit.
142
+ - `FAIL` — the result is empty, invalid, the return code is nonzero, or the run was abandoned.
143
+ - `LIMIT` — the result is empty and the log signals a ChatGPT quota limit. The quota is spent and
144
+ the task was not completed; do not restart it.
145
+
146
+ The script return code mirrors the status: `0` / `1` / `3`. A nonzero code is not a reason to
147
+ retry, change the command, or investigate on your own.
148
+
149
+ For `scope`, `OK — scope: sufficient; …` means the listed paths cover the question; continue with
150
+ `--phase advise` only when the orchestrator supplies that continuation. `OK — scope: insufficient;
151
+ N paths named` followed by a `Missing:` line is also an `OK` run, not a `LIMIT`: the orchestrator
152
+ may add context or continue with `advise`. `LIMIT` alone means the ChatGPT quota is spent.
153
+
154
+ For `advise`, expect `OK — recommend <option_id>: …`, followed by `Rejected:`, `Counter:`, and
155
+ `Risks:` lines. The recommendation names one option id, rejected options include their cost,
156
+ `Counter:` gives the strongest counterargument, and `Risks:` is the pre-mortem. Forward those lines
157
+ verbatim; compose nothing.
158
+
159
+ The run folder contains its status and artifacts. An abandoned run is not a reason to start over
160
+ yourself: the orchestrator decides whether to continue, and without the `--continue` it issued,
161
+ the runner will reject that continuation itself.
162
+
163
+ ## Codex is unavailable
164
+
165
+ The runner checks `codex --version` before starting. If the binary is missing or authentication
166
+ failed, it prints a ready-made `FAIL` with a verification command for the operator. Return that
167
+ output. Performing the task manually instead of Codex is prohibited for any reason it fails.
168
+
169
+ ## What a violation looks like
170
+
171
+ Correct (runner output copied exactly):
172
+
173
+ ```
174
+ ATTACH=<artifact root>\myproject\2026-09-22_1412_advisor started=2026-09-22T14:12:03.000Z
175
+ OK — recommend service-boundary: the interface keeps ownership local
176
+ Rejected: shared-kernel — every consumer now pays for cross-domain coupling
177
+ Counter: a shared contract could reduce duplicated validation
178
+ Risks: the boundary may be wrong if a second consumer needs synchronous access
179
+ Report: ...\report.md · Log: ...\2026-09-22_1412_advisor
180
+ ```
181
+
182
+ Incorrect — "Codex started in the background. Waiting for the advice to finish — I will notify
183
+ you." There will be no notification: as a dispatcher, you terminate with the response, so nobody
184
+ can wait and send one. Background execution is prohibited above.
185
+
186
+ Incorrect — "I reviewed the design manually because Codex could not read one of the paths," followed
187
+ by recommendations. This defeats the boundary: all reading and reasoning belong to the Codex run.
@@ -51,6 +51,10 @@ job is to report the run status honestly, including failure.
51
51
  heading, one line. You never read that file and never put the command on the command line: an
52
52
  operator's real check command contained `&&`, and a compound operator in an argument makes the
53
53
  host stop applying its permission rule, which is how a delegation dies on a refusal.
54
+ - Every build task file must contain exactly one `advice:` line from the orchestrator: an absolute
55
+ path to an existing run folder whose `meta.json` identifies `codex-advisor`, or exactly
56
+ `mechanical`, `revert`, `docs-only`, or `test-only`. Anything else is refused before the run
57
+ starts. Pass the task file as supplied; the orchestrator owns this line.
54
58
 
55
59
  ## When the host refuses the command
56
60
 
@@ -9,10 +9,11 @@ argument-hint: "[hooks|plugins on|off] | [reset]"
9
9
  symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
10
10
  package is installed. -->
11
11
 
12
- Environment switches for `codex-scout` / `codex-build` / `codex-review` runs. By default the hooks
13
- and plugins from `~/.codex` do not reach a delegated run: they are written for interactive work and
14
- do harm in an automated one — the failing `Stop` hook of `oh-my-codex` would not let Codex leave the
15
- session, and instead of doing the task it quarantined `.omx/state/session.json`. The operator's
12
+ Environment switches for `codex-scout` / `codex-build` / `codex-review` / `codex-advisor` runs.
13
+ By default the hooks and plugins from `~/.codex` do not reach a delegated run: they are written for
14
+ interactive work and do harm in an automated one — the failing `Stop` hook of `oh-my-codex` would
15
+ not let Codex leave the session, and instead of doing the task it quarantined
16
+ `.omx/state/session.json`. The operator's
16
17
  interactive Codex is untouched: the flags are set on one specific runner invocation,
17
18
  `~/.codex/hooks.json` is not modified.
18
19
 
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Defines each delegated agent's role, wall-clock budget, write access and result file.
3
+ * D2: a budget declares positive minutes or named phases; budgets.mjs alone normalizes it.
3
4
  *
4
5
  * The three agents were listed four times: twice in run-config.mjs for budgets and models,
5
6
  * once in codex-args.mjs for role lookup, and once in order-gate.mjs for guarded names.
@@ -10,6 +11,8 @@ export const AGENTS = {
10
11
  'codex-scout': { role: 'scout', budget: 15, writes: false, result: 'result.json' },
11
12
  'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
12
13
  'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
14
+ // Plan_59: design advice needs a bounded scope pass before the continued decision pass.
15
+ 'codex-advisor': { role: 'advisor', budget: { scope: 5, advise: 15 }, writes: false, result: 'result.json' },
13
16
  };
14
17
 
15
18
  export const agentRole = (agent) => AGENTS[agent]?.role;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Resolves every role's wall-clock budget into a map of phase names to minutes.
3
+ *
4
+ * A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
5
+ * on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
6
+ * killed callers left their Codex processes and token spend unrecorded. Plan_59 D2 made the
7
+ * phase a runner concept: a number is shorthand for one `default` phase, and it is expanded
8
+ * here and nowhere else, so no consumer ever has to ask which of the two shapes it holds.
9
+ */
10
+ import { AGENTS } from './agents.mjs';
11
+
12
+ export function resolveBudgets(file, overrides = {}, registry = AGENTS) {
13
+ const roles = Object.values(registry).map(({ role }) => role);
14
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
15
+ throw new Error(`${file}: key “budgets” must be an object keyed by ${roles.join(', ')}, ` +
16
+ `each holding a positive number of minutes or a phase map, not ${JSON.stringify(overrides)}`);
17
+ }
18
+ const normalize = (budget, key, defaults) => {
19
+ if (typeof budget === 'string' && !budget.trim()) {
20
+ throw new Error(`${file}: key “${key}” is empty; remove the field to use the default, ` +
21
+ 'or give it a positive number of minutes or a phase map');
22
+ }
23
+ const scalar = typeof budget === 'number';
24
+ // A number for a role with named phases would replace its whole map with `default`: the
25
+ // config would read cleanly and every run of that role would then be refused.
26
+ if (scalar && defaults && Object.keys(defaults).some((phase) => phase !== 'default')) {
27
+ throw new Error(`${file}: key “${key}” must be a phase map for this role, ` +
28
+ `e.g. {${Object.keys(defaults).map((phase) => `"${phase}": ${defaults[phase]}`).join(', ')}}, ` +
29
+ `not ${JSON.stringify(budget)}`);
30
+ }
31
+ if (scalar) budget = { default: budget };
32
+ if (!budget || typeof budget !== 'object' || Array.isArray(budget)) {
33
+ throw new Error(`${file}: key “${key}” must be a positive number of minutes or a phase map, ` +
34
+ `not ${JSON.stringify(budget)}`);
35
+ }
36
+ for (const [phase, minutes] of Object.entries(budget)) {
37
+ if (!phase.trim()) throw new Error(`${file}: key “${key}” has an empty phase name`);
38
+ if (defaults && !scalar && !Object.hasOwn(defaults, phase)) {
39
+ throw new Error(`${file}: key “${key}” has unknown phase “${phase}”. ` +
40
+ `Only ${Object.keys(defaults).join(', ')} are allowed`);
41
+ }
42
+ if (!Number.isFinite(minutes) || minutes <= 0) {
43
+ throw new Error(`${file}: key “${key}.${phase}” must be a positive number of minutes, ` +
44
+ `not ${JSON.stringify(minutes)}`);
45
+ }
46
+ }
47
+ // D2: a scalar is a default-only budget; only an object requests a partial phase override.
48
+ return { ...(scalar ? {} : defaults), ...budget };
49
+ };
50
+ const budgets = Object.fromEntries(Object.values(registry).map(({ role, budget }) => {
51
+ const phases = normalize(budget, `budgets.${role}`);
52
+ if (!Object.keys(phases).length) {
53
+ throw new Error(`${file}: key “budgets.${role}” must declare at least one phase`);
54
+ }
55
+ return [role, phases];
56
+ }));
57
+ for (const [role, budget] of Object.entries(overrides)) {
58
+ if (!roles.includes(role)) {
59
+ throw new Error(`${file}: key “budgets” has unknown role “${role}”. Only ${roles.join(', ')} are allowed`);
60
+ }
61
+ budgets[role] = normalize(budget, `budgets.${role}`, budgets[role]);
62
+ }
63
+ return budgets;
64
+ }
@@ -1,5 +1,6 @@
1
1
  /** Shared by reading and editing so Plan_56 D27 cannot persist an unreadable config. */
2
2
  import { AGENTS } from './agents.mjs';
3
+ import { resolveBudgets } from './budgets.mjs';
3
4
 
4
5
  const BUDGET_KEY = 'budgets';
5
6
  export const ROLES = Object.values(AGENTS).map(({ role }) => role);
@@ -31,7 +32,7 @@ export const DEFAULTS = {
31
32
  hooks: false,
32
33
  plugins: false,
33
34
  models: {},
34
- budgets: Object.fromEntries(Object.values(AGENTS).map(({ role, budget }) => [role, budget])),
35
+ budgets: resolveBudgets('agent registry'),
35
36
  retention: DEFAULT_RETENTION,
36
37
  environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
37
38
  answerLanguage: 'English',
@@ -88,43 +89,6 @@ function readRetention(file, value) {
88
89
  return { enabled: true, days: value.days };
89
90
  }
90
91
 
91
- /**
92
- * A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
93
- * on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
94
- * killed callers left their Codex processes and token spend unrecorded.
95
- */
96
- function readBudgets(file, value) {
97
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
98
- throw new Error(
99
- `${file}: key “${BUDGET_KEY}” must be an object keyed by ${ROLES.join(', ')}, ` +
100
- `each holding a positive number of minutes, not ${JSON.stringify(value)}`,
101
- );
102
- }
103
- const budgets = { ...DEFAULTS.budgets };
104
- for (const [role, minutes] of Object.entries(value)) {
105
- if (!ROLES.includes(role)) {
106
- throw new Error(
107
- `${file}: key “${BUDGET_KEY}” has unknown role “${role}”. ` +
108
- `Only ${ROLES.join(', ')} are allowed`,
109
- );
110
- }
111
- if (typeof minutes === 'string' && !minutes.trim()) {
112
- throw new Error(
113
- `${file}: key “${BUDGET_KEY}.${role}” is empty; remove the field to use the default ` +
114
- `(${DEFAULTS.budgets[role]} minutes), or give it a positive number of minutes`,
115
- );
116
- }
117
- if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
118
- throw new Error(
119
- `${file}: key “${BUDGET_KEY}.${role}” must be a positive number of minutes, ` +
120
- `not ${JSON.stringify(minutes)}`,
121
- );
122
- }
123
- budgets[role] = minutes;
124
- }
125
- return budgets;
126
- }
127
-
128
92
  export function validateRunConfig(file, parsed) {
129
93
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
94
  throw new Error(`${file} must contain an object like {"hooks": false, "plugins": false}`);
@@ -145,7 +109,7 @@ export function validateRunConfig(file, parsed) {
145
109
  continue;
146
110
  }
147
111
  if (key === BUDGET_KEY) {
148
- config[key] = readBudgets(file, value);
112
+ config[key] = resolveBudgets(file, value);
149
113
  continue;
150
114
  }
151
115
  if (key === RETENTION_KEY) {
@@ -0,0 +1,61 @@
1
+ /** Converts a finished advisor run into judge inputs because schemas accept well-formed agreement; Plan_59 D3/D4/D5/D10 require the judge to reject it. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { DEFAULTS } from '../run-config.mjs';
5
+ import { judgeAdvice } from './advice-verdict.mjs';
6
+ import { readJson } from './paths.mjs';
7
+
8
+ function contractReason(reasons) {
9
+ const message = `advice contract broken (${reasons.length}): ${reasons.slice(0, 2).join('; ')}`;
10
+ return message.length <= 200 ? message : `${message.slice(0, 197)}...`;
11
+ }
12
+
13
+ export function adviceGap(runDir, result, eventData) {
14
+ const worker = readJson(path.join(runDir, 'worker.json')) || {};
15
+ const status = readJson(path.join(runDir, 'status.json')) || {};
16
+ const env = readJson(path.join(runDir, 'env.json')) || {};
17
+ const phase = worker.phase ?? status.phase;
18
+ const taskFile = path.join(runDir, 'advisor-task.json');
19
+ const task = readJson(taskFile);
20
+ const reasons = [];
21
+
22
+ if (!fs.existsSync(taskFile)) reasons.push('advisor-task.json is missing');
23
+ else if (!Array.isArray(task?.options) || !Array.isArray(task?.paths)) {
24
+ reasons.push('advisor-task.json is missing parsed options or paths');
25
+ }
26
+ if (phase !== 'scope' && phase !== 'advise') reasons.push('worker.json/status.json is missing a valid advisor phase');
27
+ if (typeof status.repo !== 'string' || !status.repo) reasons.push('status.json#repo is missing');
28
+ if (!Number.isInteger(eventData?.commands_executed) || eventData.commands_executed < 0) {
29
+ reasons.push('eventData.commands_executed is missing or invalid');
30
+ }
31
+ if (!result || typeof result !== 'object' || Array.isArray(result)) reasons.push('result.json is missing or is not an object');
32
+
33
+ let scopeResult;
34
+ if (phase === 'advise') {
35
+ if (typeof status.continued_from !== 'string' || !status.continued_from) {
36
+ reasons.push('status.json#continued_from for the phase-1 result is missing');
37
+ } else {
38
+ const scopeFile = path.join(path.dirname(runDir), status.continued_from, 'result.json');
39
+ scopeResult = readJson(scopeFile);
40
+ if (!scopeResult || typeof scopeResult !== 'object' || !Array.isArray(scopeResult.predicted_risks)) {
41
+ reasons.push('phase-1 result.json or its predicted_risks is missing');
42
+ }
43
+ }
44
+ }
45
+ if (reasons.length) return contractReason(reasons);
46
+
47
+ try {
48
+ const verdict = judgeAdvice({
49
+ phase,
50
+ result,
51
+ task,
52
+ repoRoot: status.repo,
53
+ commandsRun: eventData.commands_executed,
54
+ language: env.answerLanguage ?? DEFAULTS.answerLanguage,
55
+ scopeResult,
56
+ });
57
+ return verdict.ok ? null : contractReason(verdict.reasons);
58
+ } catch (error) {
59
+ return contractReason([`result.json is missing required fields or has an unexpected shape (${error.message})`]);
60
+ }
61
+ }