@lyupro/codex-bridge 0.6.5 → 0.6.6

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,26 @@ 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.6] - 2026-09-23
8
+
9
+ ### Fixed
10
+
11
+ - The advise phase is handed the risks it is judged on. `--continue` only grants permission to
12
+ continue: Codex starts a fresh session, and the advise prompt never listed the `predicted_risks` or
13
+ `missing_paths` of the scope run — it even forbade reading those paths — while the judge read them
14
+ from the scope run folder on its own. Every advise run therefore failed with
15
+ `risk_outcomes … count 0`. The launcher now takes one snapshot of the scope result into the advise
16
+ run's `advisor-task.json` (field `scope`) before the run folder exists; the prompt's
17
+ "Scope phase results" section is rendered from that snapshot and the judge reads only it. A scope
18
+ result that cannot be carried is refused before the run starts, spending no quota.
19
+ - Repeating a `--continue` call returns the verdict of the run it started. The launcher starts a run
20
+ and returns, and only a repeated identical call attaches and returns the verdict — but a
21
+ continuation refused to attach, and the repeat was then rejected as reusing a spent grant. No
22
+ continuation of any agent could bring its verdict back to chat: a dispatcher reported `FAIL` while
23
+ its run was still working. A `--continue` call now first attaches to the run of the same order id
24
+ whose `continued_from` names the grant; with no such run a new pass starts as before. The grant stays
25
+ single-use.
26
+
7
27
  ## [0.6.5] - 2026-09-23
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -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 **1491 automated tests: 1490 passing and 1 skipped**.
65
+ The current suite contains **1501 automated tests: 1500 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.5",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 1491 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.6",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 1501 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -32,14 +32,10 @@ export function adviceGap(runDir, result, eventData) {
32
32
 
33
33
  let scopeResult;
34
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');
35
+ if (!task?.scope || !Array.isArray(task.scope.predicted_risks)) {
36
+ reasons.push('advisor-task.json#scope is missing the phase-1 predicted_risks');
37
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
- }
38
+ scopeResult = { predicted_risks: task.scope.predicted_risks, missing_paths: task.scope.missing_paths };
43
39
  }
44
40
  }
45
41
  if (reasons.length) return contractReason(reasons);
@@ -0,0 +1,50 @@
1
+ /** Owns the scope snapshot shared by advise readers because run 2026-09-23_141341_plan59-a1-stdin-hang left them with different inputs. */
2
+ import path from 'node:path';
3
+ import { readJsonFileSync } from '../json-file.mjs';
4
+ import { parseAdvisorTask } from '../meta/advisor-task.mjs';
5
+ import { die } from './args.mjs';
6
+
7
+ export function advisorTaskArtifact({ taskText, phase, runsRoot, grantRun }) {
8
+ const task = parseAdvisorTask(taskText);
9
+ if (phase !== 'advise') return task;
10
+ if (typeof runsRoot !== 'string' || !runsRoot || typeof grantRun !== 'string' || !grantRun) {
11
+ throw new Error('Cannot carry advise scope result: runsRoot and grantRun are required.');
12
+ }
13
+ const scopeFile = path.join(runsRoot, grantRun, 'result.json');
14
+ let result;
15
+ try {
16
+ result = readJsonFileSync(scopeFile);
17
+ } catch (error) {
18
+ throw new Error(`Cannot read advise scope result at ${scopeFile}: ${error.message}`);
19
+ }
20
+ if (!result || typeof result !== 'object' || Array.isArray(result) ||
21
+ !Array.isArray(result.predicted_risks) || !Array.isArray(result.missing_paths) ||
22
+ result.predicted_risks.some((risk) => !risk || typeof risk.id !== 'string' || typeof risk.risk !== 'string') ||
23
+ result.missing_paths.some((entry) => typeof entry !== 'string')) {
24
+ throw new Error(`Malformed advise scope result at ${scopeFile}: expected predicted_risks and missing_paths arrays.`);
25
+ }
26
+ return { ...task, scope: { run: grantRun, predicted_risks: result.predicted_risks, missing_paths: result.missing_paths } };
27
+ }
28
+
29
+ // Called before makeRunDir: a scope result that cannot be carried is known before any quota is
30
+ // spent, so it refuses like every other preflight gate instead of leaving a registered run behind.
31
+ export function advisorTaskOrRefuse(input) {
32
+ try {
33
+ return advisorTaskArtifact(input);
34
+ } catch (error) {
35
+ return die(`${error.message} The run folder was not created; quota was not spent.`, 1);
36
+ }
37
+ }
38
+
39
+ export function adviseSection(artifact) {
40
+ if (!artifact?.scope) return '';
41
+ return [
42
+ '## Scope phase results',
43
+ '',
44
+ 'Predicted risks:',
45
+ ...artifact.scope.predicted_risks.map(({ id, risk }) => `- ${id}: ${risk}`),
46
+ '',
47
+ 'Paths you may additionally read and cite:',
48
+ ...artifact.scope.missing_paths.map((entry) => `- ${entry}`),
49
+ ].join('\n');
50
+ }
@@ -5,6 +5,7 @@ import { readJsonFileSync } from '../json-file.mjs';
5
5
  import { workerMayBeAlive } from '../meta/run-liveness.mjs';
6
6
  import { processAlive } from '../process-identity.mjs';
7
7
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
8
+ import { sameRun } from './continuation.mjs';
8
9
  import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
9
10
 
10
11
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
@@ -93,18 +94,21 @@ export async function waitForReply(runDir, workerPid, status) {
93
94
  * path only covers a live run that has not recorded a verdict yet.
94
95
  *
95
96
  * A repeat that arrives after the verdict is answered from disk rather than refused, for the same
96
- * reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
97
- * the previous reply and wants another pass.
97
+ * reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
98
+ * incident showed that repeating the identical continuation command must return to that run.
98
99
  */
99
- export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
100
- if (isContinue) return null;
100
+ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, grantRun, isContinue, noWait } = {}) {
101
101
  const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
102
102
  const runRecords = runs
103
103
  .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
104
104
  .filter(({ status }) => status);
105
105
  const sameOrder = runsForOrder(runRecords, orderId);
106
+ const attachRuns = isContinue
107
+ ? sameOrder.filter(({ status }) => grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
108
+ : sameOrder;
109
+ if (isContinue && !attachRuns.length) return null;
106
110
 
107
- const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
111
+ const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
108
112
  if (owner) {
109
113
  const ownerDir = path.join(runsRoot, owner.run);
110
114
  console.log(
@@ -120,8 +124,8 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
120
124
  // order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
121
125
  // a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
122
126
  let candidate = null;
123
- for (let i = sameOrder.length - 1; i >= 0; i -= 1) {
124
- const entry = sameOrder[i];
127
+ for (let i = attachRuns.length - 1; i >= 0; i -= 1) {
128
+ const entry = attachRuns[i];
125
129
  const dir = path.join(runsRoot, entry.run);
126
130
  if (fs.existsSync(path.join(dir, 'reply.txt'))) {
127
131
  announceSavedReply(dir, orderId, entry.status.started_at);
@@ -9,7 +9,9 @@ import path from 'node:path';
9
9
  import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
10
  import { readJson } from '../write-meta.mjs';
11
11
 
12
- const sameRun = (runsRootPath, left, right) => {
12
+ // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
13
+ // disagree about whether two spellings name the same folder.
14
+ export const sameRun = (runsRootPath, left, right) => {
13
15
  const a = path.resolve(runsRootPath, left);
14
16
  const b = path.resolve(runsRootPath, right);
15
17
  return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
@@ -27,7 +27,7 @@ import { parseArgs, die } from './args.mjs';
27
27
  import { settleTaskInput } from './task-input.mjs';
28
28
  import { parseContinuationGrant } from '../required-inputs.mjs';
29
29
  import { continuationRefusal } from './continuation.mjs';
30
- import { parseAdvisorTask } from '../meta/advisor-task.mjs';
30
+ import { advisorTaskOrRefuse, adviseSection } from './advise-carry.mjs';
31
31
  import { schemaFor } from './schemas.mjs';
32
32
  import { INSTRUCTIONS } from './prompts.mjs';
33
33
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
@@ -146,12 +146,18 @@ export async function launcher(argv = process.argv.slice(2)) {
146
146
  const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
147
147
  const startedChain = startedRuns(projectRunsRoot, chain);
148
148
  const continuationGrant = parseContinuationGrant(taskText);
149
+ const attachExistingRun = () => attach({
150
+ runsRoot: projectRunsRoot, repo: repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId, chain,
151
+ grantRun: continuationGrant?.run, isContinue: opts.continue, noWait: opts.noWait,
152
+ });
153
+ let attachedExitCode = opts.continue ? await attachExistingRun() : null;
154
+ if (attachedExitCode !== null) return attachedExitCode;
149
155
 
150
156
  // The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
151
157
  // a grant without its flag must refuse before attach can print an older run's verdict.
152
158
  // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
153
159
  // A run without a verdict can therefore only still be in flight; ordinary repeats go through
154
- // attach(), while --continue must refuse to overlap that live work.
160
+ // attach() after this gate. Repeated --continue calls attach first because their grant is spent.
155
161
  const continuationError = continuationRefusal(
156
162
  projectRunsRoot,
157
163
  startedChain,
@@ -164,20 +170,13 @@ export async function launcher(argv = process.argv.slice(2)) {
164
170
  const scopeRun = continuationGrant &&
165
171
  scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
166
172
  if (scopeRun) die(scopeRun, 1);
173
+ // Plan_59 D22: the advise snapshot is read before the folder exists, so a broken scope result refuses for free.
174
+ const advisorTask = opts.agent === 'codex-advisor' ? advisorTaskOrRefuse({ taskText, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant?.run }) : null;
167
175
 
168
176
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
169
177
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
170
178
  // before creating a folder, probing Codex or spending another token.
171
- const attachedExitCode = await attach({
172
- runsRoot: projectRunsRoot,
173
- repo: repoRoot,
174
- slug: opts.slug,
175
- taskHash,
176
- orderId: opts.orderId,
177
- chain,
178
- isContinue: opts.continue,
179
- noWait: opts.noWait,
180
- });
179
+ if (!opts.continue) attachedExitCode = await attachExistingRun();
181
180
  if (attachedExitCode !== null) return attachedExitCode;
182
181
 
183
182
  if (startedChain.length && !opts.continue) {
@@ -274,7 +273,7 @@ export async function launcher(argv = process.argv.slice(2)) {
274
273
  if (opts.agent === 'codex-scout') {
275
274
  fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
276
275
  }
277
- if (opts.agent === 'codex-advisor') fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(parseAdvisorTask(taskText), null, 2)}\n`);
276
+ if (advisorTask) fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(advisorTask, null, 2)}\n`);
278
277
 
279
278
  if (opts.agent === 'codex-build') {
280
279
  fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
@@ -289,6 +288,8 @@ export async function launcher(argv = process.argv.slice(2)) {
289
288
  // and what may be edited. Both also go to disk as questions.json / scope.txt, so the verdict
290
289
  // is computed from the same list Codex was handed, not from a second reading of the wording.
291
290
  const sections = [`## Operator task (verbatim)\n\n${taskText}`];
291
+ const scopeResults = adviseSection(advisorTask);
292
+ if (scopeResults) sections.push(scopeResults);
292
293
  if (questions.length) {
293
294
  sections.push(
294
295
  [
@@ -16,7 +16,7 @@ const BODIES = {
16
16
  return `Judge a DESIGN before code exists.
17
17
 
18
18
  Rules for both phases:
19
- - Read ONLY the paths listed under \`## Paths\`, plus \`git log\` / \`git show\` on those paths.
19
+ - Read ONLY the paths listed under \`## Paths\` and, in the advise phase, under \`## Scope phase results\`, plus \`git log\` / \`git show\` on those paths.
20
20
  - Write nothing. Do your own reading and checks; do not spawn or delegate to other agents.
21
21
  - Every claim about code must carry a \`path:line\` address inside those paths. Addresses are
22
22
  machine-checked for existence; do not invent addresses or treat an unchecked claim as evidence.
@@ -32,7 +32,7 @@ Scope phase:
32
32
  (facts accepted from the task without checking, never empty), together with predicted_risks.
33
33
  ` : `
34
34
  Advise phase:
35
- - Settle every predicted risk from the scope run in risk_outcomes: its risk_id, an outcome of
35
+ - Settle every predicted risk listed under \`## Scope phase results\` in risk_outcomes: its risk_id, an outcome of
36
36
  confirmed or refuted, and an address supporting that outcome.
37
37
  - Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
38
38
  Put every rejected task option in rejected with its cost; for none-of-these this means EVERY