@lyupro/codex-bridge 0.1.2 → 0.1.4

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.
@@ -9,6 +9,7 @@
9
9
  * The lines built here ARE the reply. Agents forward this text verbatim instead of
10
10
  * composing prose, which is what keeps a delegated run at five lines.
11
11
  */
12
+ import fs from 'node:fs';
12
13
  import path from 'node:path';
13
14
  import { changedPaths, line, readText } from './paths.mjs';
14
15
  import { splitRunChanges } from './environment.mjs';
@@ -113,10 +114,32 @@ export function failReply(ctx, meta) {
113
114
  return [
114
115
  `FAIL — ${line(meta.reason, 170)}`,
115
116
  `Artifacts: raw.log ${meta.log_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
117
+ // A failed build says what it left behind, exactly as a LIMIT does. "The work was not
118
+ // done" is not "the tree is clean": a run can write half a change and then declare fail,
119
+ // and the orchestrator has to know whether there is something to revert before it decides
120
+ // anything else.
121
+ ...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
116
122
  `Log: ${ctx.file('raw.log')}`,
117
123
  ];
118
124
  }
119
125
 
126
+ /**
127
+ * What the run left in the tree, or an admission that nobody knows. Both snapshots are
128
+ * required: a run killed before it wrote state-after.txt has no delta to compute, and
129
+ * printing "no new changes" there would be a claim made out of missing data — the same
130
+ * mistake status.json's `tree_after: false` exists to prevent.
131
+ */
132
+ function worktreeState(runDir) {
133
+ const hasSnapshots = ['state-before.txt', 'state-after.txt'].every((f) =>
134
+ fs.existsSync(path.join(runDir, f)),
135
+ );
136
+ if (!hasSnapshots) return 'unknown — the run left no worktree snapshot, check git status';
137
+ const { work } = runChanges(runDir);
138
+ return work.length
139
+ ? `has unfinished changes (${work.length}): ${line(work.slice(0, 3).join(', '), 120)}`
140
+ : 'no new changes';
141
+ }
142
+
120
143
  export function limitReply(ctx, meta) {
121
144
  const rows = [
122
145
  'LIMIT — ChatGPT quota exhausted, work not completed',
@@ -22,6 +22,7 @@ import {
22
22
  } from './paths.mjs';
23
23
  import { chainBaseline } from './chain.mjs';
24
24
  import { splitRunChanges } from './environment.mjs';
25
+ import { outcomeGap } from './outcome.mjs';
25
26
 
26
27
  /**
27
28
  * Quota exhaustion is a transport error, not a word. Both markers must sit on the same
@@ -306,6 +307,13 @@ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, ru
306
307
  reason: `verification “${line(result.verify_command || 'no command', 60)}” failed; changes remain in the tree`,
307
308
  };
308
309
  }
310
+ // What the executor says about its own work, after verification and ahead of scope: work
311
+ // that was never done matters more than where it was not done. Only runs whose schema.json
312
+ // demanded the field are judged by it — see outcome.mjs.
313
+ if (agent === 'codex-build') {
314
+ const declared = outcomeGap(runDir, result);
315
+ if (declared) return { status: 'FAIL', reason: declared };
316
+ }
309
317
  // Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
310
318
  // only when questions.json is absent — see scoutCoverageGap().
311
319
  if (agent === 'codex-scout') {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Defines the inputs the orchestrator must give each Codex dispatcher.
3
+ *
4
+ * This contract belongs beside the gate because the dispatcher prompt was the only place that
5
+ * named order id and scope, so the caller launched codex-build without either value and the
6
+ * runner refused the call before Codex could do any work. Pass 2 can render the same entries
7
+ * into agent instructions without creating a second list that can drift.
8
+ */
9
+
10
+ const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id', 'scope', 'xxx']);
11
+
12
+ const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
13
+
14
+ export const CONTINUATION_INPUT = Object.freeze({
15
+ label: 'continue',
16
+ source: 'the orchestrator',
17
+ explanation:
18
+ 'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
19
+ example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
20
+ conditional: 'when --continue is passed',
21
+ });
22
+
23
+ export const REQUIRED_INPUTS = Object.freeze({
24
+ 'codex-scout': freezeEntries([
25
+ {
26
+ label: 'order id',
27
+ source: 'the orchestrator',
28
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
29
+ example: 'plan-13-scout-20260804',
30
+ },
31
+ CONTINUATION_INPUT,
32
+ ]),
33
+ 'codex-build': freezeEntries([
34
+ {
35
+ label: 'order id',
36
+ source: 'the orchestrator',
37
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
38
+ example: 'plan-13-build-20260804',
39
+ },
40
+ {
41
+ label: 'scope',
42
+ source: 'the orchestrator',
43
+ explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
44
+ example: 'src/runner/**,tests/runner/**',
45
+ },
46
+ CONTINUATION_INPUT,
47
+ ]),
48
+ 'codex-review': freezeEntries([
49
+ {
50
+ label: 'order id',
51
+ source: 'the orchestrator',
52
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
53
+ example: 'plan-13-review-20260804',
54
+ },
55
+ CONTINUATION_INPUT,
56
+ ]),
57
+ });
58
+
59
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+
61
+ function cleanValue(value) {
62
+ if (typeof value !== 'string') return '';
63
+ return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
64
+ }
65
+
66
+ export function isPlaceholder(value) {
67
+ const cleaned = cleanValue(value);
68
+ if (!cleaned) return true;
69
+ if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
70
+ return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
71
+ }
72
+
73
+ export function extractValue(promptText, label) {
74
+ const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
75
+ const flag = `--${label.replaceAll(/\s+/g, '-')}`;
76
+ const direct = new RegExp(
77
+ `(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
78
+ 'im',
79
+ );
80
+ const directMatch = promptText.match(direct);
81
+ if (directMatch) return cleanValue(directMatch[1]);
82
+
83
+ const flagPattern = new RegExp(
84
+ `${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|([^\\s\\r\\n]+))`,
85
+ 'i',
86
+ );
87
+ const flagMatch = promptText.match(flagPattern);
88
+ if (!flagMatch) return null;
89
+ return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
90
+ }
91
+
92
+ /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
93
+ export function parseContinuationGrant(promptText) {
94
+ const prompt = typeof promptText === 'string' ? promptText : '';
95
+ const value = extractValue(prompt, CONTINUATION_INPUT.label);
96
+ if (isPlaceholder(value)) return null;
97
+
98
+ for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
99
+ const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
100
+ if (!match) continue;
101
+ const run = cleanValue(match[1]);
102
+ const reason = cleanValue(match[2]);
103
+ if (isPlaceholder(run) || isPlaceholder(reason)) return null;
104
+ return { run, reason };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /** Returns the immutable input entries for one dispatcher type. */
110
+ export function requiredInputsFor(agentType) {
111
+ return REQUIRED_INPUTS[agentType] || [];
112
+ }
113
+
114
+ /** Returns required entries whose value is absent or is still an obvious template placeholder. */
115
+ export function missingInputs(agentType, promptText) {
116
+ const prompt = typeof promptText === 'string' ? promptText : '';
117
+ return requiredInputsFor(agentType).filter(
118
+ (entry) => !entry.conditional && isPlaceholder(extractValue(prompt, entry.label)),
119
+ );
120
+ }
121
+
122
+ /** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
123
+ export function renderRequiredInputSummary(agentType) {
124
+ const entries = requiredInputsFor(agentType);
125
+ if (!entries.length) return '';
126
+ const labels = entries.map((entry) => {
127
+ const label = `\`${entry.label}\``;
128
+ return entry.conditional ? `${label} (${entry.conditional})` : label;
129
+ });
130
+ // Commas until the last pair: the list grew to three entries when the continuation grant
131
+ // joined it, and "a and b and c" reads as a stutter in the description the orchestrator
132
+ // sees while choosing a dispatcher.
133
+ const rendered = labels.length > 1
134
+ ? `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
135
+ : labels[0];
136
+ return `Requires ${entries[0].source}-provided ${rendered}.`;
137
+ }
138
+
139
+ /** Renders the same contract for pass 2 agent instructions. */
140
+ export function renderRequiredInputs(agentType) {
141
+ return requiredInputsFor(agentType)
142
+ .map((entry) => {
143
+ const condition = entry.conditional ? ` Condition: ${entry.conditional}.` : '';
144
+ return `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.${condition}`;
145
+ })
146
+ .join('\n');
147
+ }
@@ -8,12 +8,17 @@
8
8
  import path from 'node:path';
9
9
  import { AGENTS } from '../write-meta.mjs';
10
10
  import { ALLOWED_EFFORTS } from '../run-config.mjs';
11
+ import { requiredInputsFor } from '../required-inputs.mjs';
11
12
 
12
13
  export function die(message) {
13
14
  console.error(`run-codex: ${message}`);
14
15
  process.exit(2);
15
16
  }
16
17
 
18
+ function requiredInput(agentType, label) {
19
+ return requiredInputsFor(agentType).find((entry) => entry.label === label);
20
+ }
21
+
17
22
  // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
18
23
  // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
19
24
  // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
@@ -63,10 +68,12 @@ export function parseArgs(argv) {
63
68
  if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
64
69
  opts.orderId = String(opts['order-id'] ?? '').trim();
65
70
  if (!opts.orderId) {
71
+ const orderInput = requiredInput(opts.agent, 'order id');
66
72
  die(
67
- '--order-id is required: the orchestrator issues the order label and the runner will not invent one. ' +
73
+ `--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
68
74
  'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
69
- 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update.',
75
+ 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
76
+ `Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
70
77
  );
71
78
  }
72
79
  if (opts.agent === 'codex-scout') {
@@ -101,10 +108,11 @@ export function parseArgs(argv) {
101
108
  .map((p) => p.trim())
102
109
  .filter(Boolean);
103
110
  if (opts.agent === 'codex-build' && !opts.scopePatterns.length) {
111
+ const scopeInput = requiredInput(opts.agent, 'scope');
104
112
  die(
105
- '--scope is required for codex-build: comma-separated globs relative to the repo root, ' +
106
- 'e.g. --scope "packages/event-calendar/**,apps/orchestrator/src/maestro/calendar-refresh.ts". ' +
107
- 'Anything outside the list is off limits and fails the run.',
113
+ `--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
114
+ `${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
115
+ `Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
108
116
  );
109
117
  }
110
118
  return opts;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Owns the zero-quota decision about whether a requested continuation was ordered.
3
+ *
4
+ * The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
5
+ * only the orchestrator read that verdict and can name the run and reason it is willing to fund.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
+ import { readJson } from '../write-meta.mjs';
11
+
12
+ const sameRun = (runsRootPath, left, right) => {
13
+ const a = path.resolve(runsRootPath, left);
14
+ const b = path.resolve(runsRootPath, right);
15
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
16
+ };
17
+
18
+ function namedRunDirectory(runsRootPath, run) {
19
+ if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
20
+ const directory = path.join(runsRootPath, run);
21
+ try {
22
+ return fs.statSync(directory).isDirectory() ? directory : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function normalizeGrant(grant) {
29
+ if (typeof grant === 'string') return parseContinuationGrant(grant);
30
+ if (!grant || typeof grant !== 'object') return null;
31
+ const run = String(grant.run ?? '').trim();
32
+ const reason = String(grant.reason ?? '').trim();
33
+ return run && reason ? { run, reason } : null;
34
+ }
35
+
36
+ const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
37
+ const grantAction =
38
+ 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
39
+
40
+ /**
41
+ * Applies the one-continuation limit and all continuation safety gates before a folder is created.
42
+ *
43
+ * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
44
+ * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
45
+ * repeat that renamed itself — but counting continuations that way makes the escape hatch
46
+ * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
47
+ * id lands in the same chain through the task hash. A task would then be refused with --continue
48
+ * for having spent its continuation and refused without it for already having runs — permanently
49
+ * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
50
+ */
51
+ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
52
+ if (!isContinue) return null;
53
+
54
+ const continuation = normalizeGrant(grant);
55
+ if (!continuation) {
56
+ return (
57
+ '--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
58
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
59
+ );
60
+ }
61
+
62
+ const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
63
+ if (!namedDirectory) {
64
+ return (
65
+ `--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
66
+ `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
67
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
68
+ );
69
+ }
70
+
71
+ const last = chain[chain.length - 1];
72
+ // Continuing the last run appends a later run, so the old grant stops matching by itself; this
73
+ // incident needs no counter or new state to make an orchestrator grant single-use.
74
+ if (!last || !sameRun(runsRootPath, continuation.run, last)) {
75
+ return (
76
+ `--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
77
+ `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
78
+ 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
79
+ `state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
80
+ );
81
+ }
82
+
83
+ const wanted = String(orderId ?? '').trim();
84
+ const ofThisOrder = chain.filter(
85
+ (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
86
+ );
87
+ if (ofThisOrder.length === 0) return null;
88
+ if (ofThisOrder.length > 1) {
89
+ const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
90
+ return (
91
+ `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
92
+ 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
93
+ 'created; quota was not spent.'
94
+ );
95
+ }
96
+ const previous = path.join(runsRootPath, ofThisOrder[0]);
97
+ const status = readJson(path.join(previous, 'status.json'));
98
+ const meta = readJson(path.join(previous, 'meta.json'));
99
+ if (!meta?.status || status?.state === 'running') {
100
+ return (
101
+ `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
102
+ 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
103
+ 'not created; quota was not spent.'
104
+ );
105
+ }
106
+ return null;
107
+ }
@@ -28,6 +28,8 @@ import { attach } from './attach.mjs';
28
28
  import { setRun } from './run-context.mjs';
29
29
  import { loadRunEnv, RUN_ENV } from './run-env.mjs';
30
30
  import { parseArgs, die } from './args.mjs';
31
+ import { parseContinuationGrant } from '../required-inputs.mjs';
32
+ import { continuationRefusal } from './continuation.mjs';
31
33
  import { SCHEMAS } from './schemas.mjs';
32
34
  import { INSTRUCTIONS } from './prompts.mjs';
33
35
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
@@ -114,6 +116,7 @@ export async function launcher() {
114
116
  // like --scope.
115
117
  const taskHash = taskFingerprint(taskText);
116
118
  const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
119
+ const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
117
120
 
118
121
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
119
122
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
@@ -132,7 +135,13 @@ export async function launcher() {
132
135
  // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
133
136
  // A run without a verdict can therefore only still be in flight; ordinary repeats go through
134
137
  // attach(), while --continue must refuse to overlap that live work.
135
- const continuationError = continuationRefusal(projectRunsRoot, chain, opts.continue, opts.orderId);
138
+ const continuationError = continuationRefusal(
139
+ projectRunsRoot,
140
+ chain,
141
+ opts.continue,
142
+ opts.orderId,
143
+ continuationGrant,
144
+ );
136
145
  if (continuationError) die(continuationError);
137
146
 
138
147
  if (chain.length && !opts.continue) {
@@ -183,6 +192,8 @@ export async function launcher() {
183
192
  // Which run of this task started the chain — the base every later pass is measured
184
193
  // against. Absent means this is the first pass.
185
194
  ...(chain.length ? { continues: chain[0] } : {}),
195
+ // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
196
+ ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
186
197
  });
187
198
 
188
199
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
@@ -333,42 +344,3 @@ export async function launcher() {
333
344
  );
334
345
  process.exit(0);
335
346
  }
336
-
337
- /**
338
- * Applies the one-continuation limit and the in-flight safety gate before a folder is created.
339
- *
340
- * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
341
- * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
342
- * repeat that renamed itself — but counting continuations that way makes the escape hatch
343
- * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
344
- * id lands in the same chain through the task hash. A task would then be refused with --continue
345
- * for having spent its continuation and refused without it for already having runs — permanently
346
- * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
347
- */
348
- export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
349
- if (!isContinue || chain.length === 0) return null;
350
- const wanted = String(orderId ?? '').trim();
351
- const ofThisOrder = chain.filter(
352
- (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
353
- );
354
- if (ofThisOrder.length === 0) return null;
355
- if (ofThisOrder.length > 1) {
356
- const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
357
- return (
358
- `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
359
- 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
360
- 'created; quota was not spent.'
361
- );
362
- }
363
- const previous = path.join(runsRootPath, ofThisOrder[0]);
364
- const status = readJson(path.join(previous, 'status.json'));
365
- const meta = readJson(path.join(previous, 'meta.json'));
366
- if (!meta?.status || status?.state === 'running') {
367
- return (
368
- `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
369
- 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
370
- 'not created; quota was not spent.'
371
- );
372
- }
373
- return null;
374
- }
@@ -52,6 +52,15 @@ Working rules:
52
52
  cannot be done, do not pretend it is done; list it in leftovers.
53
53
  - After making changes, run verification: ${opts.verify || 'determine the command from the project'}.
54
54
  Put the command actually run in verify_command and whether it passed in verify_passed.
55
+ - Declare the outcome in outcome: "done" if the work the task asked for was carried out,
56
+ "fail" if it was not — for any reason at all, including an impossible order, a file that
57
+ does not exist, a scope that forbids the only fix, or running out of time. "done" with an
58
+ empty changes[] is correct only when the task asked you to check something and nothing
59
+ needed changing; if you could not do what was asked, "fail" is the answer even when the
60
+ worktree is clean and verification is green. This is not a self-grade: work that was done
61
+ and merely leaves follow-ups is "done" with the follow-ups in leftovers, and partial work
62
+ that satisfies the definition of done is "done" too. On "fail" the first sentence of summary
63
+ states the reason — it is what the orchestrator is shown.
55
64
  - summary and changes describe only changes in this repository for this task. Do not include
56
65
  anything unrelated in them.
57
66
  - Each change is a separate changes[] entry, exactly one file per entry. The file field contains
@@ -51,8 +51,30 @@ const BUILD_SCHEMA = {
51
51
  $schema: 'https://json-schema.org/draft/2020-12/schema',
52
52
  type: 'object',
53
53
  additionalProperties: false,
54
- required: ['summary', 'changes', 'verify_command', 'verify_passed', 'leftovers', 'report_markdown'],
54
+ required: [
55
+ 'outcome',
56
+ 'summary',
57
+ 'changes',
58
+ 'verify_command',
59
+ 'verify_passed',
60
+ 'leftovers',
61
+ 'report_markdown',
62
+ ],
55
63
  properties: {
64
+ // The outcome is declared, never inferred. On 2026-08-04 a build asked to fix a function
65
+ // in a module that does not exist answered `OK — no code change was made`: every artifact
66
+ // was well-formed, the tree was legitimately clean, and the runner has no way to know
67
+ // whether the order required an edit. A string rather than a boolean because the set of
68
+ // outcomes will grow (`blocked` is a candidate) and an enum extends without breaking the
69
+ // contract. Presence of this field in the run's own schema.json is also the marker that
70
+ // says the run was contracted to declare an outcome — see meta/outcome.mjs.
71
+ outcome: {
72
+ type: 'string',
73
+ enum: ['done', 'fail'],
74
+ description:
75
+ 'done — the work the task asked for was carried out. fail — it was not, for any ' +
76
+ 'reason at all (impossible order, missing file, blocked by scope, out of time).',
77
+ },
56
78
  summary: { type: 'string', minLength: 1 },
57
79
  changes: {
58
80
  type: 'array',