@lyupro/codex-bridge 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/cli/command-registry.mjs +1 -1
  4. package/cli/guidance-render.mjs +57 -0
  5. package/cli/guidance-topics.mjs +8 -0
  6. package/cli/guidance.mjs +9 -10
  7. package/package.json +2 -2
  8. package/scripts/home-write-inventory.mjs +2 -0
  9. package/scripts/run-tests.mjs +44 -0
  10. package/src/claude/rules/core.md +5 -4
  11. package/src/home/guidance/order.md +12 -13
  12. package/src/home/guidance/records.md +3 -1
  13. package/src/home/hooks/dispatcher-gate.mjs +8 -2
  14. package/src/home/hooks/order-gate.mjs +16 -15
  15. package/src/home/lib/dispatcher-call.mjs +27 -24
  16. package/src/home/lib/dispatcher-command.mjs +12 -6
  17. package/src/home/lib/dispatcher-gate.mjs +20 -16
  18. package/src/home/lib/dispatcher-model.mjs +7 -8
  19. package/src/home/lib/dispatcher-pin.mjs +7 -6
  20. package/src/home/lib/dispatcher-protocol.mjs +76 -4
  21. package/src/home/lib/dispatcher-state.mjs +4 -3
  22. package/src/home/lib/install-owner-roots.mjs +57 -55
  23. package/src/home/lib/meta/advisor-task.mjs +21 -12
  24. package/src/home/lib/meta/reply.mjs +1 -10
  25. package/src/home/lib/meta/unavailable-rows.mjs +11 -0
  26. package/src/home/lib/order-diagnostics/cli-flags.mjs +56 -0
  27. package/src/home/lib/order-diagnostics/filesystem.mjs +18 -0
  28. package/src/home/lib/order-diagnostics/host-state.mjs +263 -0
  29. package/src/home/lib/order-diagnostics/internal.mjs +82 -0
  30. package/src/home/lib/order-diagnostics/run-state.mjs +58 -0
  31. package/src/home/lib/order-diagnostics.mjs +48 -0
  32. package/src/home/lib/order-rules/call.mjs +130 -0
  33. package/src/home/lib/order-rules/header-grants.mjs +175 -0
  34. package/src/home/lib/order-rules/header.mjs +206 -0
  35. package/src/home/lib/order-rules/task-document.mjs +80 -0
  36. package/src/home/lib/order-rules.mjs +77 -0
  37. package/src/home/lib/order-schema.mjs +206 -200
  38. package/src/home/lib/order-text-record.mjs +104 -0
  39. package/src/home/lib/order-values.mjs +46 -46
  40. package/src/home/lib/runner/advise-carry.mjs +9 -7
  41. package/src/home/lib/runner/args.mjs +23 -26
  42. package/src/home/lib/runner/codex-availability.mjs +10 -6
  43. package/src/home/lib/runner/continuation.mjs +158 -174
  44. package/src/home/lib/runner/launcher.mjs +19 -17
  45. package/src/home/lib/runner/order-claim.mjs +15 -9
  46. package/src/home/lib/runner/order-options.mjs +3 -2
  47. package/src/home/lib/runner/order-owner.mjs +9 -7
  48. package/src/home/lib/runner/pass-gate.mjs +14 -18
  49. package/src/home/lib/runner/preflight.mjs +25 -35
  50. package/src/home/lib/runner/question-kind.mjs +4 -2
  51. package/src/home/lib/runner/retired-roots.mjs +18 -9
  52. package/src/home/lib/runner/runs-root.mjs +3 -3
  53. package/src/home/lib/runner/sandbox-diagnosis.mjs +17 -17
  54. package/src/home/lib/runner/sandbox-probe.mjs +18 -27
  55. package/src/home/lib/runner/scope-check.mjs +30 -23
  56. package/src/home/lib/runner/task-file.mjs +6 -4
  57. package/src/home/lib/runner/task-input.mjs +12 -16
  58. package/src/home/lib/runner/worker-admission.mjs +5 -2
  59. package/src/home/lib/subagent-model-env.mjs +5 -6
  60. package/src/home/lib/task-header.mjs +159 -158
@@ -4,25 +4,26 @@ import path from 'node:path';
4
4
  import { readJsonFileSync } from '../json-file.mjs';
5
5
  import { parseAdvisorTask } from '../meta/advisor-task.mjs';
6
6
  import { die } from './args.mjs';
7
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
7
8
 
8
9
  export function advisorTaskArtifact({ taskText, phase, runsRoot, grantRun }) {
9
10
  const task = parseAdvisorTask(taskText);
10
11
  if (phase !== 'advise') return task;
11
12
  if (typeof runsRoot !== 'string' || !runsRoot || typeof grantRun !== 'string' || !grantRun) {
12
- throw new Error('Cannot carry advise scope result: runsRoot and grantRun are required.');
13
+ throw new Error(orderDiagnosticMessage('advise-scope-input-required'));
13
14
  }
14
15
  const scopeFile = path.join(runsRoot, grantRun, 'result.json');
15
16
  let result;
16
17
  try {
17
18
  result = readJsonFileSync(scopeFile);
18
19
  } catch (error) {
19
- throw new Error(`Cannot read advise scope result at ${scopeFile}: ${error.message}`);
20
+ throw new Error(orderDiagnosticMessage('advise-scope-result-readable', { scopeFile, message: error.message }));
20
21
  }
21
22
  if (!result || typeof result !== 'object' || Array.isArray(result) ||
22
23
  !Array.isArray(result.predicted_risks) || !Array.isArray(result.missing_paths) ||
23
24
  result.predicted_risks.some((risk) => !risk || typeof risk.id !== 'string' || typeof risk.risk !== 'string') ||
24
25
  result.missing_paths.some((entry) => typeof entry !== 'string')) {
25
- throw new Error(`Malformed advise scope result at ${scopeFile}: expected predicted_risks and missing_paths arrays.`);
26
+ throw new Error(orderDiagnosticMessage('advise-scope-result-shape', { scopeFile }));
26
27
  }
27
28
  return { ...task, scope: { run: grantRun, predicted_risks: result.predicted_risks, missing_paths: result.missing_paths } };
28
29
  }
@@ -33,13 +34,13 @@ export function advisorTaskOrRefuse(input) {
33
34
  try {
34
35
  return advisorTaskArtifact(input);
35
36
  } catch (error) {
36
- return die(`${error.message} The run folder was not created; quota was not spent.`, EXIT.FAIL);
37
+ return die([error.message, orderDiagnosticMessage('run-not-created')].join(' '), EXIT.FAIL);
37
38
  }
38
39
  }
39
40
 
40
41
  export function adviseSection(artifact) {
41
- if (!artifact?.scope) return '';
42
- return [
42
+ if (!artifact?.scope) return String();
43
+ const lines = [
43
44
  '## Scope phase results',
44
45
  '',
45
46
  'Predicted risks:',
@@ -47,5 +48,6 @@ export function adviseSection(artifact) {
47
48
  '',
48
49
  'Paths you may additionally read and cite:',
49
50
  ...artifact.scope.missing_paths.map((entry) => `- ${entry}`),
50
- ].join('\n');
51
+ ];
52
+ return lines.join('\n');
51
53
  }
@@ -12,7 +12,10 @@ import { isAbsoluteTaskFilePath } from '../order-values.mjs';
12
12
  import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
13
13
  import { parseTaskDocument } from './task-file.mjs';
14
14
  import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
15
- import { renderOrderHeaderHelp } from '../order-schema.mjs';
15
+ import { renderOrderHeaderHelp } from '../order-schema.mjs';
16
+ import { orderRuleMessage, orderRefusalAt } from '../order-rules.mjs';
17
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
18
+ import { checkOutgoingRefusal } from '../order-text-record.mjs';
16
19
 
17
20
  export class RunnerUsageError extends Error {
18
21
  // 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
@@ -24,8 +27,9 @@ export class RunnerUsageError extends Error {
24
27
  }
25
28
  }
26
29
 
27
- export function die(message, exitCode = EXIT.USAGE) {
28
- console.error(`run-codex: ${message}`);
30
+ export function die(message, exitCode = EXIT.USAGE) {
31
+ checkOutgoingRefusal(message, 'die');
32
+ console.error(`run-codex: ${message}`);
29
33
  throw new RunnerUsageError(message, exitCode);
30
34
  }
31
35
 
@@ -34,39 +38,39 @@ export function readTaskDocument(opts) {
34
38
  const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
35
39
  if (opts['task-file'] !== undefined) {
36
40
  if (stdinText.trim()) {
37
- refuse('task text was supplied through both stdin and --task-file; choose exactly one channel');
41
+ refuse(orderDiagnosticMessage('task-channel-conflict'));
38
42
  }
39
43
  const taskFile = opts['task-file'];
40
44
  // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
41
45
  // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
42
46
  if (!isAbsoluteTaskFilePath(taskFile)) {
43
- refuse(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
47
+ refuse(orderRuleMessage('task-file-path', { value: taskFile }));
44
48
  }
45
49
  let fileText;
46
50
  try {
47
51
  fileText = fs.readFileSync(taskFile, 'utf8');
48
52
  } catch (err) {
49
- refuse(`task file from --task-file could not be read: ${err.message}`);
53
+ refuse(orderDiagnosticMessage('task-file-readable', { message: err.message }));
50
54
  }
51
- if (!fileText.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
55
+ if (!fileText.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'task-file', taskFile }));
52
56
  // Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
53
57
  const parsed = parseTaskHeader(fileText);
54
58
  const refusal = taskHeaderRefusal(parsed);
55
- if (refusal) refuse(`${taskFile}: ${refusal}`);
56
- if (!parsed.body.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
59
+ if (refusal) refuse(orderRefusalAt(taskFile, refusal));
60
+ if (!parsed.body.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'task-file', taskFile }));
57
61
  let document;
58
62
  try {
59
63
  document = parseTaskDocument(parsed.body);
60
64
  } catch (err) {
61
- refuse(`${taskFile}: ${err.message}`);
65
+ refuse(orderRefusalAt(taskFile, err.message));
62
66
  }
63
67
  return { ...document, header: parsed };
64
68
  }
65
- if (!stdinText.trim()) refuse('task text on stdin is empty');
69
+ if (!stdinText.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'stdin' }));
66
70
  const parsed = parseTaskHeader(stdinText);
67
71
  const refusal = taskHeaderRefusal(parsed);
68
72
  if (refusal) refuse(refusal);
69
- if (!parsed.body.trim()) refuse('task text on stdin is empty');
73
+ if (!parsed.body.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'stdin' }));
70
74
  try {
71
75
  return { ...parseTaskDocument(parsed.body), header: parsed };
72
76
  } catch (err) {
@@ -94,22 +98,18 @@ export function parseArgs(argv) {
94
98
  const opts = {};
95
99
  for (let i = 0; i < argv.length; i += 1) {
96
100
  const key = argv[i];
97
- if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
101
+ if (!key.startsWith('--')) die(orderDiagnosticMessage('unexpected-argument', { value: key }));
98
102
  const name = key.slice(2);
99
103
  if (!['agent', 'task-file', 'no-wait'].includes(name)) {
100
104
  const agent = opts.agent ?? argv[argv.indexOf('--agent') + 1];
101
105
  const help = Object.hasOwn(AGENTS, agent) ? renderOrderHeaderHelp(agent) : '';
102
- die(`unknown flag --${name}: codex-bridge run takes only --agent, --task-file and --no-wait; ` +
103
- 'the order belongs in the task-file header' + (help ? `\n${help}` : ''));
106
+ die(orderDiagnosticMessage('unknown-flag', { name, help }));
104
107
  }
105
108
  const value = argv[i + 1];
106
109
  if (BOOLEAN_FLAGS.has(name)) {
107
110
  if (value !== undefined && !value.startsWith('--')) {
108
111
  if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
109
- die(
110
- `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
111
- 'A placeholder left in from the prompt template is not consent.',
112
- );
112
+ die(orderDiagnosticMessage('boolean-flag-value', { name, value }));
113
113
  }
114
114
  opts[name] = BOOLEAN_YES.test(value);
115
115
  i += 1;
@@ -119,7 +119,7 @@ export function parseArgs(argv) {
119
119
  continue;
120
120
  }
121
121
  // A flag name where a value belongs means the value was left out.
122
- if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
122
+ if (value === undefined || value.startsWith('--')) die(orderDiagnosticMessage('missing-flag-value', { key }));
123
123
  opts[name] = value;
124
124
  i += 1;
125
125
  }
@@ -127,14 +127,11 @@ export function parseArgs(argv) {
127
127
  if (opts[name] === undefined) continue;
128
128
  const sequence = firstShellUnsafeSequence(opts[name]);
129
129
  if (sequence !== null) {
130
- die(
131
- `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
132
- 'put free text in the task file and pass only a short command-line value.',
133
- );
130
+ die(orderDiagnosticMessage('flag-shell-safety', { name, sequence }));
134
131
  }
135
132
  }
136
- if (!opts.agent) die('--agent is required');
137
- if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
133
+ if (!opts.agent) die(orderDiagnosticMessage('agent-required'));
134
+ if (!AGENTS[opts.agent]) die(orderDiagnosticMessage('agent-known', { agent: opts.agent }));
138
135
  opts.noWait = Boolean(opts['no-wait']);
139
136
  return opts;
140
137
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { resolveCommandOnPath } from '../command-path.mjs';
6
6
  import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
7
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
7
8
 
8
9
  // A signed-in answer takes ~200 ms; ten seconds bounds a hung CLI without delaying a healthy start.
9
10
  export const PROBE_TIMEOUT_MS = 10_000;
@@ -13,9 +14,10 @@ function lines(text) {
13
14
  }
14
15
 
15
16
  function failureDetail(result, command) {
16
- return lines(result.stderr).at(-1)
17
- || lines(result.error?.message).at(-1)
18
- || `${command} exited ${result.status}`;
17
+ const message = lines(result.stderr).at(-1) || lines(result.error?.message).at(-1);
18
+ return orderDiagnosticMessage('codex-availability-detail', message
19
+ ? { variant: 'message', message }
20
+ : { variant: 'exit-status', command, status: result.status });
19
21
  }
20
22
 
21
23
  export async function probeCodexAvailability({
@@ -31,7 +33,7 @@ export async function probeCodexAvailability({
31
33
  if (resolve('codex', env) === null) {
32
34
  return {
33
35
  state: 'inconclusive', pathMiss: true,
34
- detail: 'codex could not be resolved in this process PATH; readiness is unconfirmed',
36
+ detail: orderDiagnosticMessage('codex-availability-detail', { variant: 'path-miss' }),
35
37
  };
36
38
  }
37
39
  }
@@ -50,10 +52,12 @@ export async function probeCodexAvailability({
50
52
  }
51
53
  if (login.status === 0) return { state: 'available' };
52
54
  if (login.status === 1 && [...lines(login.stderr), ...lines(login.stdout)].includes('Not logged in')) {
53
- return { state: 'logged-out', detail: 'codex login status: Not logged in' };
55
+ return { state: 'logged-out', detail: orderDiagnosticMessage('codex-availability-detail', { variant: 'logged-out' }) };
54
56
  }
55
57
  return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
56
58
  } catch (error) {
57
- return { state: 'inconclusive', detail: lines(error?.message).at(-1) || 'codex availability probe failed' };
59
+ const message = lines(error?.message).at(-1);
60
+ return { state: 'inconclusive', detail: orderDiagnosticMessage('codex-availability-detail', message
61
+ ? { variant: 'message', message } : { variant: 'failed' }) };
58
62
  }
59
63
  }
@@ -1,174 +1,158 @@
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_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
10
- import { readJson } from '../write-meta.mjs';
11
- import { workerMayBeAlive } from '../meta/run-liveness.mjs';
12
-
13
- // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
14
- // disagree about whether two spellings name the same folder.
15
- export const sameRun = (runsRootPath, left, right) => {
16
- const a = path.resolve(runsRootPath, left);
17
- const b = path.resolve(runsRootPath, right);
18
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
19
- };
20
-
21
- function namedRunDirectory(runsRootPath, run) {
22
- if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
23
- const directory = path.join(runsRootPath, run);
24
- try {
25
- return fs.statSync(directory).isDirectory() ? directory : null;
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
- function normalizeGrant(grant) {
32
- if (!grant || typeof grant !== 'object') return null;
33
- const run = String(grant.run ?? '').trim();
34
- const reason = String(grant.reason ?? '').trim();
35
- return run && reason ? { run, reason } : null;
36
- }
37
-
38
- const grantExample = `Example: \`continue: ${CONTINUATION_ORDER_INPUT.example}\`.`;
39
- const grantAction =
40
- 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
41
-
42
- // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
43
- // carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
44
- function lastRunOutcome(runsRootPath, chain) {
45
- const run = chain[chain.length - 1];
46
- if (!run) return null;
47
- const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
48
- const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
49
- const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
50
- const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
51
- return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
52
- }
53
-
54
- export function readyGrantLines(runsRootPath, chain, grantReason) {
55
- const last = lastRunOutcome(runsRootPath, chain);
56
- if (!last) return 'Ready grant line: none (there is no run to continue).';
57
- const reason = String(grantReason ?? last.reason).trim() || last.reason;
58
- const continuation = `Ready grant line: continue: ${last.run} — ${reason}.`;
59
- // Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
60
- // A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
61
- const orderId = String(last.orderId ?? '').trim();
62
- if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
63
- return continuation +
64
- ` Ready retry line: retry: ${last.run} — ${last.reason} (repeats that pass under order ${orderId}).`;
65
- }
66
-
67
- function lastRunHints(runsRootPath, chain, grantReason) {
68
- const last = lastRunOutcome(runsRootPath, chain);
69
- if (!last) {
70
- return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
71
- }
72
- return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
73
- readyGrantLines(runsRootPath, chain, grantReason);
74
- }
75
-
76
- /**
77
- * Applies the one-continuation limit and all continuation safety gates before a folder is created.
78
- *
79
- * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
80
- * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
81
- * repeat that renamed itself — but counting continuations that way makes the escape hatch
82
- * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
83
- * id lands in the same chain through the task hash. A task would then be refused with a grant
84
- * for having spent its continuation and refused without one for already having runs — permanently
85
- * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
86
- */
87
- export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
88
- const continuation = normalizeGrant(grant);
89
- // Plan_63 D9: only header grant objects authorize a pass; a string is no grant.
90
- if (!isContinue || !continuation) return null;
91
-
92
- const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
93
- if (!namedDirectory) {
94
- return (
95
- `${orderInputName('continue')} is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
96
- `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
97
- `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
98
- `${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
99
- 'The run folder was not created; quota was not spent.'
100
- );
101
- }
102
-
103
- const last = chain[chain.length - 1];
104
- // Continuing the last run appends a later run, so the old grant stops matching by itself; this
105
- // incident needs no counter or new state to make an orchestrator grant single-use.
106
- if (!last || !sameRun(runsRootPath, continuation.run, last)) {
107
- return (
108
- `${orderInputName('continue')} is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
109
- `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
110
- 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
111
- `state is used. ${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
112
- `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
113
- );
114
- }
115
-
116
- const wanted = String(orderId ?? '').trim();
117
- // Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
118
- const ofThisOrder = chain.filter((run) => {
119
- const status = readJson(path.join(runsRootPath, run, 'status.json'));
120
- return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
121
- });
122
- if (ofThisOrder.length === 0) return null;
123
- if (ofThisOrder.length > 1) {
124
- const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
125
- return (
126
- `${orderInputName('continue')} is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
127
- `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
128
- 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
129
- 'created; quota was not spent.'
130
- );
131
- }
132
- const previous = path.join(runsRootPath, ofThisOrder[0]);
133
- const status = readJson(path.join(previous, 'status.json'));
134
- const meta = readJson(path.join(previous, 'meta.json'));
135
- if (!meta?.status || status?.state === 'running') {
136
- return (
137
- `${orderInputName('continue')} is refused: previous run ${previous} has no finished verdict and may still ` +
138
- `be editing the worktree. Repeat without ${orderInputName('continue')} to attach to it. ` +
139
- `${lastRunHints(runsRootPath, chain, continuation.reason)} The run folder was ` +
140
- 'not created; quota was not spent.'
141
- );
142
- }
143
- return null;
144
- }
145
-
146
- export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
147
- const refuse = (message) => `${message} The run folder was not created; quota was not spent.`;
148
- const runDir = namedRunDirectory(runsRootPath, grant?.run);
149
- if (!runDir) {
150
- return refuse(`Retry is refused: ${grant?.run || 'none'} is not a bare existing run folder in ${runsRootPath}.`);
151
- }
152
- const last = chain[chain.length - 1];
153
- // Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
154
- if (!last || !sameRun(runsRootPath, grant.run, last)) {
155
- return refuse(`Retry is refused: grant ${grant.run} is not the LAST run of this task's chain; a retry is single-use.`);
156
- }
157
- const status = readJson(path.join(runDir, 'status.json'));
158
- const wanted = String(orderId ?? '').trim();
159
- const previousOrder = String(status?.order_id ?? '').trim();
160
- if (!previousOrder || previousOrder !== wanted) {
161
- return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
162
- 'A retry repeats its own order; a run without order_id predates orders — start a new order.');
163
- }
164
- const meta = readJson(path.join(runDir, 'meta.json'));
165
- // Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
166
- if (!meta?.status || liveness({ runDir, status })) {
167
- return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
168
- `Repeat without ${orderInputName('retry')} to attach to it.`);
169
- }
170
- if (meta.status === 'OK') {
171
- return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
172
- }
173
- return null;
174
- }
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_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
10
+ import { orderRuleMessage } from '../order-rules.mjs';
11
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
12
+ import { readJson } from '../write-meta.mjs';
13
+ import { workerMayBeAlive } from '../meta/run-liveness.mjs';
14
+
15
+ // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
16
+ // disagree about whether two spellings name the same folder.
17
+ export const sameRun = (runsRootPath, left, right) => {
18
+ const a = path.resolve(runsRootPath, left);
19
+ const b = path.resolve(runsRootPath, right);
20
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
21
+ };
22
+
23
+ function namedRunDirectory(runsRootPath, run) {
24
+ if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
25
+ const directory = path.join(runsRootPath, run);
26
+ try {
27
+ return fs.statSync(directory).isDirectory() ? directory : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function normalizeGrant(grant) {
34
+ if (!grant || typeof grant !== 'object') return null;
35
+ const run = String(grant.run ?? '').trim();
36
+ const reason = String(grant.reason ?? '').trim();
37
+ return run && reason ? { run, reason } : null;
38
+ }
39
+
40
+ // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
41
+ // carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
42
+ function lastRunOutcome(runsRootPath, chain) {
43
+ const run = chain[chain.length - 1];
44
+ if (!run) return null;
45
+ const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
46
+ const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
47
+ const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
48
+ const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
49
+ return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
50
+ }
51
+
52
+ export function readyGrantLines(runsRootPath, chain, grantReason) {
53
+ const last = lastRunOutcome(runsRootPath, chain);
54
+ if (!last) return orderRuleMessage('ready-grant-line', { variant: 'none' });
55
+ const reason = String(grantReason ?? last.reason).trim() || last.reason;
56
+ const continuation = orderRuleMessage('ready-grant-line', { variant: 'continue', run: last.run, reason });
57
+ // Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
58
+ // A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
59
+ const orderId = String(last.orderId ?? '').trim();
60
+ if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
61
+ return [continuation, orderRuleMessage('ready-grant-line', {
62
+ variant: 'retry', run: last.run, reason: last.reason, orderId,
63
+ })].join(' ');
64
+ }
65
+
66
+ function lastRunHints(runsRootPath, chain, grantReason) {
67
+ const last = lastRunOutcome(runsRootPath, chain);
68
+ if (!last) {
69
+ return [orderRuleMessage('grant-last-run-hint', { variant: 'none' }),
70
+ orderRuleMessage('ready-grant-line', { variant: 'none' })].join(' ');
71
+ }
72
+ return [orderRuleMessage('grant-last-run-hint', {
73
+ variant: 'last', run: last.run, status: last.status, reason: last.reason,
74
+ }), readyGrantLines(runsRootPath, chain, grantReason)].join(' ');
75
+ }
76
+
77
+ /**
78
+ * Applies the one-continuation limit and all continuation safety gates before a folder is created.
79
+ *
80
+ * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
81
+ * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
82
+ * repeat that renamed itself — but counting continuations that way makes the escape hatch
83
+ * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
84
+ * id lands in the same chain through the task hash. A task would then be refused with a grant
85
+ * for having spent its continuation and refused without one for already having runs — permanently
86
+ * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
87
+ */
88
+ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
89
+ const continuation = normalizeGrant(grant);
90
+ // Plan_63 D9: only header grant objects authorize a pass; a string is no grant.
91
+ if (!isContinue || !continuation) return null;
92
+
93
+ const refuse = (violation, values) => [orderRuleMessage('continuation-grant-usable', {
94
+ violation, labelName: orderInputName('continue'), run: continuation.run,
95
+ hints: lastRunHints(runsRootPath, chain, continuation.reason), ...values,
96
+ }), orderDiagnosticMessage('run-not-created')].join(' ');
97
+
98
+ const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
99
+ if (!namedDirectory) {
100
+ return refuse('missing-folder', { runsRoot: runsRootPath, example: CONTINUATION_ORDER_INPUT.example });
101
+ }
102
+
103
+ const last = chain[chain.length - 1];
104
+ // Continuing the last run appends a later run, so the old grant stops matching by itself; this
105
+ // incident needs no counter or new state to make an orchestrator grant single-use.
106
+ if (!last || !sameRun(runsRootPath, continuation.run, last)) {
107
+ return refuse('not-last', { lastRun: last || 'none', example: CONTINUATION_ORDER_INPUT.example });
108
+ }
109
+
110
+ const wanted = String(orderId ?? '').trim();
111
+ // Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
112
+ const ofThisOrder = chain.filter((run) => {
113
+ const status = readJson(path.join(runsRootPath, run, 'status.json'));
114
+ return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
115
+ });
116
+ if (ofThisOrder.length === 0) return null;
117
+ if (ofThisOrder.length > 1) {
118
+ const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
119
+ return refuse('spent', { orderId: wanted, spent });
120
+ }
121
+ const previous = path.join(runsRootPath, ofThisOrder[0]);
122
+ const status = readJson(path.join(previous, 'status.json'));
123
+ const meta = readJson(path.join(previous, 'meta.json'));
124
+ if (!meta?.status || status?.state === 'running') {
125
+ return refuse('unfinished', { previous });
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
131
+ const refuse = (violation, values) => [orderRuleMessage('retry-grant-usable', {
132
+ violation, run: grant?.run || 'none', ...values,
133
+ }), orderDiagnosticMessage('run-not-created')].join(' ');
134
+ const runDir = namedRunDirectory(runsRootPath, grant?.run);
135
+ if (!runDir) {
136
+ return refuse('missing-folder', { runsRoot: runsRootPath });
137
+ }
138
+ const last = chain[chain.length - 1];
139
+ // Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
140
+ if (!last || !sameRun(runsRootPath, grant.run, last)) {
141
+ return refuse('not-last', {});
142
+ }
143
+ const status = readJson(path.join(runDir, 'status.json'));
144
+ const wanted = String(orderId ?? '').trim();
145
+ const previousOrder = String(status?.order_id ?? '').trim();
146
+ if (!previousOrder || previousOrder !== wanted) {
147
+ return refuse('order-mismatch', { previousOrder: previousOrder || '(missing)', orderId: wanted });
148
+ }
149
+ const meta = readJson(path.join(runDir, 'meta.json'));
150
+ // Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
151
+ if (!meta?.status || liveness({ runDir, status })) {
152
+ return refuse('unfinished', { labelName: orderInputName('retry') });
153
+ }
154
+ if (meta.status === 'OK') {
155
+ return refuse('ended-ok', {});
156
+ }
157
+ return null;
158
+ }