@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +7 -4
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Holds one kernel claim per project run store and exact order id during preparation.
3
+ * Plan_60 D4/D4c: on 2026-09-24 two launchers started 2.8 seconds apart with one order id
4
+ * and both ran and billed; a kernel-held claim queues preparation and dies with its holder.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import path from 'node:path';
8
+ import {
9
+ acquireKernelLock, directoryDigest, kernelLockAddress, kernelLockStrategy,
10
+ } from '../kernel-lock.mjs';
11
+ import { parseJsonText } from '../json-file.mjs';
12
+ import { chainRuns, readJson } from '../write-meta.mjs';
13
+
14
+ // Plan_60 D4/D4c: preparation takes seconds; expiry must refuse rather than permit double billing.
15
+ export const ORDER_CLAIM_TIMING = Object.freeze({ waitMs: 60_000, retryMs: 100, answerTimeoutMs: 1_000 });
16
+
17
+ export function orderClaimAddress(projectRunsRoot, orderId, platform = process.platform) {
18
+ if (typeof orderId !== 'string' || orderId.length === 0) {
19
+ throw new TypeError('orderId must be a non-empty string');
20
+ }
21
+ // Plan_60 A4 r5: owner matching is case-sensitive; retries must retain the exact order id.
22
+ const digest = directoryDigest(projectRunsRoot, { suffix: orderId });
23
+ if (digest === null) throw new Error(`project runs folder does not exist: ${projectRunsRoot}`);
24
+ if (kernelLockStrategy(platform) === null) return null;
25
+ return kernelLockAddress('order', digest, platform);
26
+ }
27
+
28
+ export async function acquireOrderClaim({
29
+ projectRunsRoot, orderId, role, platform = process.platform,
30
+ timing = ORDER_CLAIM_TIMING, createServer,
31
+ }) {
32
+ if (role !== 'launcher' && role !== 'worker') {
33
+ throw new TypeError('role must be launcher or worker');
34
+ }
35
+ const address = orderClaimAddress(projectRunsRoot, orderId, platform);
36
+ if (address === null) return { claimed: false, unsupported: true, release: async () => {} };
37
+ const holder = { pid: process.pid, role, orderId, token: randomUUID() };
38
+ const result = await acquireKernelLock({
39
+ address, holder, ...timing, parseAnswer: parseClaimHolder, label: 'order claim', createServer,
40
+ });
41
+ if (result.held) return { claimed: true, release: result.release };
42
+ return { claimed: false, busy: true, holder: result.holder };
43
+ }
44
+
45
+ export function parseClaimHolder(line) {
46
+ try {
47
+ const holder = parseJsonText('<order claim holder>', line);
48
+ if (holder === null || typeof holder !== 'object') return null;
49
+ if (!Number.isInteger(holder.pid) || holder.pid <= 0) return null;
50
+ if (holder.role !== 'launcher' && holder.role !== 'worker') return null;
51
+ for (const key of ['orderId', 'token', 'acquiredAt']) {
52
+ if (typeof holder[key] !== 'string' || holder[key].length === 0) return null;
53
+ }
54
+ return holder;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Plan_60 D4/D4c: a holder answer diagnoses contention but cannot authorize anything.
61
+ export function orderClaimHolderText(holder) {
62
+ return holder === null
63
+ ? 'a process that did not answer (unverified)'
64
+ : `${holder.role} pid ${holder.pid} since ${holder.acquiredAt}`;
65
+ }
66
+
67
+ export function orderClaimBusyText(orderId, holder) {
68
+ return `order id "${orderId}" is being launched by ${orderClaimHolderText(holder)}; repeat the same command later — `
69
+ + 'it attaches to that run instead of starting another. The run folder was not created; quota was not spent.';
70
+ }
71
+
72
+ /**
73
+ * Plan_60 D4, 2026-09-24: macOS has no kernel claim, so re-read just before registration.
74
+ * This shrinks the double-billing window to milliseconds and never waits on another launch.
75
+ */
76
+ export function unclaimedRaceRefusal({ projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun, chainBefore }) {
77
+ const chain = chainRuns(projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun);
78
+ const before = new Set(chainBefore);
79
+ const appeared = chain.find((run) => !before.has(run)
80
+ && readJson(path.join(projectRunsRoot, run, 'status.json'))?.order_id === orderId);
81
+ if (!appeared) return null;
82
+ return `another launch of order id "${orderId}" registered ${appeared} while this one was preparing; `
83
+ + 'repeat the same command — it attaches to that run. The run folder was not created; quota was not spent.';
84
+ }
@@ -1,4 +1,5 @@
1
1
  /** Keeps the runner and producer hook on one definition of which task owns an order id. */
2
+ import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
2
3
 
3
4
  const byStartThenName = (left, right) => {
4
5
  const leftAt = String(left.status.started_at || '');
@@ -26,3 +27,20 @@ export function conflictingOrderOwner(runs, orderId, taskHash) {
26
27
  const incomingHash = String(taskHash ?? '').trim().toLowerCase();
27
28
  return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
28
29
  }
30
+
31
+ export function ownerPredatesHashScheme(owner) {
32
+ return owner.status.task_hash_scheme !== TASK_HASH_SCHEME;
33
+ }
34
+
35
+ export function orderOwnerConflictText(owner, ownerDir, orderId) {
36
+ const identity = `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
37
+ `(slug ${owner.status.slug}, started_at ${owner.status.started_at})`;
38
+ // Plan_75 D5, 2026-10-03: an old hash cannot prove that a repeated order is a different task.
39
+ if (ownerPredatesHashScheme(owner)) {
40
+ return `${identity}, recorded before the task header (hash scheme ${TASK_HASH_SCHEME}), so its task cannot be compared. ` +
41
+ `Its answer is already on disk in that folder (codex-bridge read "${ownerDir}"). ` +
42
+ 'Another pass of that order needs a continue:/retry: header line; a new task needs a new order id.';
43
+ }
44
+ return `${identity} with a different task. Use a new order id (--order-id), or a continue:/retry: header line ` +
45
+ 'if this is another pass of that order.';
46
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Decides, before anything is registered, whether a call attaches to an existing run of its order,
3
+ * is a granted continuation, or is a refused repeat.
4
+ *
5
+ * Moved out of launcher.mjs unchanged (Plan_75 P0): the launcher sat at the 400-line gate, and the
6
+ * 2026-10-03 TradeForge advise that died on model capacity showed this gate needs a retry path.
7
+ * It is handed no run directory, so like preflight.mjs it cannot leave a folder behind a refusal.
8
+ */
9
+ import path from 'node:path';
10
+ import { chainRuns, startedRuns, taskFingerprint, readJson } from '../write-meta.mjs';
11
+ import { die } from './args.mjs';
12
+ import { EXIT } from './exit-codes.mjs';
13
+ import { attach } from './attach.mjs';
14
+ import { continuationRefusal, retryRefusal, readyGrantLines } from './continuation.mjs';
15
+ import { advisorTaskOrRefuse } from './advise-carry.mjs';
16
+ import { scopeRunRefusal } from './preflight.mjs';
17
+
18
+ export async function passGate({ opts, taskText, header, projectRunsRoot, repoRoot, beforeWait }) {
19
+ // A second pass at the same task is a real need — after a timeout or a LIMIT the work has
20
+ // to be finished — so it is allowed and made visible rather than forbidden. What is
21
+ // forbidden is repeating by accident: the orchestrator saw the previous reply, the runner
22
+ // did not, so only the orchestrator can decide there is something left to finish.
23
+ //
24
+ // The task is identified by its text as well as by its slug, because the slug is chosen by
25
+ // whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
26
+ // the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
27
+ // like --scope.
28
+ const taskHash = taskFingerprint(taskText);
29
+ const grant = header.grant;
30
+ // OW-040, 2026-09-30: only a retry repeats failed scope without spending advise's continuation.
31
+ if (opts.agent === 'codex-advisor' && opts.phase === 'scope' && opts.continue && grant?.kind !== 'retry') {
32
+ die('codex-advisor --phase scope refuses --continue: a scope pass is never continued, and continuing one ' +
33
+ "spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
34
+ "without --continue and without a continue: grant, then run advise as that order's continuation; a " +
35
+ 'scope run that failed is repeated with a `retry:` grant instead. The run folder was not created; quota was not spent.');
36
+ }
37
+ let continuationGrant = grant?.kind === 'continue' ? grant : null;
38
+ const retryOf = grant?.kind === 'retry' ? grant.run : null;
39
+ const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId, grant?.run);
40
+ const startedChain = startedRuns(projectRunsRoot, chain);
41
+ const attachExistingRun = () => attach({
42
+ runsRoot: projectRunsRoot, repo: repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId, chain,
43
+ grantRun: continuationGrant?.run, isContinue: opts.continue, noWait: opts.noWait,
44
+ retryRun: retryOf ?? undefined, beforeWait,
45
+ });
46
+ let attachedExitCode = opts.continue ? await attachExistingRun() : null;
47
+ if (attachedExitCode !== null) return { exitCode: attachedExitCode };
48
+
49
+ // The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
50
+ // a grant without its flag must refuse before attach can print an older run's verdict.
51
+ // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
52
+ // A run without a verdict can therefore only still be in flight; ordinary repeats go through
53
+ // attach() after this gate. Repeated --continue calls attach first because their grant is spent.
54
+ if (grant?.kind === 'retry') {
55
+ const retryError = retryRefusal(projectRunsRoot, startedChain, opts.continue, opts.orderId, grant);
56
+ if (retryError) die(retryError);
57
+ const namedStatus = readJson(path.join(projectRunsRoot, grant.run, 'status.json'));
58
+ // A run written before phases existed has none; for every agent but the advisor that is 'default'.
59
+ const phaseOf = (value) => String(value ?? '').trim() || 'default';
60
+ if (namedStatus.agent !== opts.agent || phaseOf(namedStatus.phase) !== phaseOf(opts.phase)) {
61
+ die(`a retry repeats the named run's own agent and phase: ${namedStatus.agent}/${namedStatus.phase}. ` +
62
+ 'The run folder was not created; quota was not spent.');
63
+ }
64
+ // Plan_75 D4, TradeForge capacity incident: retry advise against its original successful scope.
65
+ continuationGrant = String(namedStatus.continued_from ?? '').trim()
66
+ ? { run: namedStatus.continued_from, reason: grant.reason } : null;
67
+ } else {
68
+ const continuationError = continuationRefusal(
69
+ projectRunsRoot,
70
+ startedChain,
71
+ opts.continue,
72
+ opts.orderId,
73
+ continuationGrant,
74
+ );
75
+ if (continuationError) die(continuationError);
76
+ }
77
+ // Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
78
+ const scopeRun = continuationGrant &&
79
+ scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
80
+ if (scopeRun) die(scopeRun, EXIT.FAIL);
81
+ // Plan_59 D22: the advise snapshot is read before the folder exists, so a broken scope result refuses for free.
82
+ const advisorTask = opts.agent === 'codex-advisor' ? advisorTaskOrRefuse({ taskText, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant?.run }) : null;
83
+
84
+ // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
85
+ // restart the synchronous launcher. A live same-order run is now the repeat target: attach
86
+ // before creating a folder, probing Codex or spending another token.
87
+ if (!opts.continue) attachedExitCode = await attachExistingRun();
88
+ if (attachedExitCode !== null) return { exitCode: attachedExitCode };
89
+
90
+ if (startedChain.length && !opts.continue) {
91
+ const last = startedChain[startedChain.length - 1];
92
+ const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
93
+ const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
94
+ die(
95
+ `--continue is required: ${
96
+ renamed
97
+ ? `this task already ran in this repository under the name “${lastSlug}”`
98
+ : `runs for task “${opts.slug}” already exist in this repository`
99
+ } (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
100
+ 'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
101
+ 'previous response and knows whether work remains. Add --continue if you are finishing ' +
102
+ 'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
103
+ `${readyGrantLines(projectRunsRoot, startedChain)} ` +
104
+ 'The run folder was not created; quota was not spent.',
105
+ );
106
+ }
107
+
108
+ return { taskHash, chain, startedChain, continuationGrant, advisorTask, retryOf };
109
+ }
@@ -6,13 +6,29 @@
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
8
  import { advisorTaskRefusal } from '../meta/advisor-task.mjs';
9
+ import { unavailableRows } from '../meta/reply.mjs';
9
10
  import { activeRunDetails, readJson } from '../write-meta.mjs';
10
- import { codexUnavailableReason } from './codex-cmd.mjs';
11
+ import { probeCodexAvailability } from './codex-availability.mjs';
11
12
  import { agentRole } from '../agents.mjs';
12
13
  import { die } from './args.mjs';
13
14
 
15
+ /** Plan_60 D2: only conclusive evidence says UNAVAILABLE; an unclear probe stays an ordinary refusal. */
16
+ export async function codexAvailabilityRefusal({ probe = probeCodexAvailability } = {}) {
17
+ const result = await probe();
18
+ if (result.state === 'available') return null;
19
+ const unavailable = result.state === 'missing' || result.state === 'logged-out';
20
+ const rows = unavailable ? unavailableRows(result.detail) : [
21
+ `Codex CLI unavailable: ${result.detail}`,
22
+ 'Operator check: codex --version (and codex login if authorization is rejected)',
23
+ ];
24
+ return {
25
+ unavailable,
26
+ text: [...rows, 'The run folder was not created; quota was not spent.'].join('\n'),
27
+ };
28
+ }
29
+
14
30
  /** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
15
- export function taskPreflight({ agent, taskText }) {
31
+ export function taskPreflight({ agent, taskText, header }) {
16
32
  const freeRefusal = (reason) => ({
17
33
  refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
18
34
  });
@@ -21,13 +37,8 @@ export function taskPreflight({ agent, taskText }) {
21
37
  return reason ? freeRefusal(reason) : { refusal: null };
22
38
  }
23
39
  if (agent !== 'codex-build') return { refusal: null };
24
- const lines = taskText.split(/\r\n|\n|\r/)
25
- .flatMap((line) => {
26
- const match = line.match(/^\s*(?:-\s+)?advice\s*:\s*(.*?)\s*$/i);
27
- return match ? [match[1]] : [];
28
- });
29
- const advice = lines[0];
30
- if (lines.length === 1) {
40
+ const advice = header.advice;
41
+ if (advice !== null) {
31
42
  if (['mechanical', 'revert', 'docs-only', 'test-only'].includes(advice)) {
32
43
  return { refusal: null, advice };
33
44
  }
@@ -44,7 +55,8 @@ export function taskPreflight({ agent, taskText }) {
44
55
  }
45
56
  }
46
57
  }
47
- return freeRefusal('codex-build requires exactly one advice: line: mechanical | revert | docs-only | test-only ' +
58
+ return freeRefusal('codex-build requires the task file to start with the header line `advice: <value>`, where the ' +
59
+ 'value is mechanical | revert | docs-only | test-only ' +
48
60
  'or an absolute path to an existing advisor run directory whose meta.json says agent codex-advisor, ' +
49
61
  'phase advise, status OK. ' +
50
62
  'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
@@ -100,13 +112,5 @@ export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
100
112
  ].join('\n');
101
113
  }
102
114
 
103
- const why = codexUnavailableReason();
104
- if (why !== null) {
105
- return [
106
- `Codex CLI unavailable: ${why}`,
107
- 'Operator check: codex --version (and codex login if authorization is rejected)',
108
- 'The run folder was not created; quota was not spent.',
109
- ].join('\n');
110
- }
111
115
  return null;
112
116
  }
@@ -4,9 +4,36 @@
4
4
  * These are data, not comments: the wording IS the contract a run is graded against, and
5
5
  * every rule in it was added by an incident. Keyed by agent name, next to the matching
6
6
  * schema in schemas.mjs — one lookup picks both halves of an agent's strategy.
7
+ * Plan_68 D6 renders array bounds from schemas after recommendation truncations on 2026-09-27
8
+ * (`…cli/install.mjs:` + U+0000) and 2026-09-28 (`…(docs/plans/Plan_` + `恋`).
7
9
  */
8
10
  import { DEFAULTS } from '../run-config.mjs';
9
11
  import { RUN_ENV } from './run-env.mjs';
12
+ import { advisorSchema, schemaFor } from './schemas.mjs';
13
+ import { QUESTION_KIND } from './question-kind.mjs';
14
+
15
+ function boundedArrayRules(schema, path = '') {
16
+ if (schema.type === 'object') {
17
+ return Object.entries(schema.properties ?? {}).flatMap(([name, child]) =>
18
+ boundedArrayRules(child, path ? `${path}.${name}` : name));
19
+ }
20
+ if (schema.type !== 'array') return [];
21
+
22
+ const { minItems, maxItems } = schema;
23
+ const quantity = minItems !== undefined && maxItems !== undefined
24
+ ? `${minItems}-${maxItems}`
25
+ : minItems !== undefined ? `at least ${minItems}` : `at most ${maxItems}`;
26
+ const noun = (maxItems ?? minItems) === 1 ? 'item' : 'items';
27
+ const ownRule = minItems === undefined && maxItems === undefined
28
+ ? [] : [`- ${path}: ${quantity} ${noun}`];
29
+ return [...ownRule, ...boundedArrayRules(schema.items, `${path}[]`)];
30
+ }
31
+
32
+ function renderArrayBounds(schema) {
33
+ const rules = boundedArrayRules(schema);
34
+ return rules.length ? `Output-schema array bounds:\n${rules.join('\n')}` : '';
35
+ }
36
+
10
37
  const BODIES = {
11
38
  // Plan_59 D3-D5/D7/D10: independent design advice needs explicit lenses and a risk ledger.
12
39
  'codex-advisor': (opts) => {
@@ -27,12 +54,13 @@ Rules for both phases:
27
54
  an opinion, not advice. Agreeing is fine when your own checks support it.
28
55
  ${opts.phase === 'scope' ? `
29
56
  Scope phase:
30
- - BEFORE reading in depth, name 3-5 predicted_risks with ids r1..r5: where this design most likely
57
+ - BEFORE reading in depth, name predicted_risks with ids r1..r5: where this design most likely
31
58
  breaks. Record those predictions first, then read; do not replace them with hindsight.
32
59
  - Answer sufficient, missing_paths (concrete repository paths you still need), and taken_on_trust
33
60
  (facts accepted from the task without checking, never empty), together with predicted_risks.
34
61
  ` : `
35
62
  Advise phase:
63
+ - Give a concise, complete recommendation that can stand on its own.
36
64
  - Settle every predicted risk listed under \`## Scope phase results\` in risk_outcomes with its
37
65
  risk_id, an outcome of confirmed or refuted, a \`note\` explaining it, and evidence.
38
66
  - Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
@@ -66,8 +94,12 @@ ${
66
94
  : `- Put one object in answers with question_id "Q1": analysis in answer, references in evidence.
67
95
  - The top-level answer is the same response summarized in 2-4 sentences.`
68
96
  }
69
- - Base the response on what you read; support every fact with a \`path:line\` reference in where.
70
- - If facts are missing, list them in unknowns; do not make them up.
97
+ - Base the response on what you read; support every fact about the code with a \`path:line\` reference in where.
98
+ ${questions.some((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
99
+ ? `- A sub-question marked [context-only] asks about what this run was handed at startup
100
+ (task, instructions, schema, environment), not about the repository. Answer it from that context,
101
+ cite evidence as \`startup:<source>\`, and never cite a repository \`path:line\` you did not read
102
+ with a command — that fails the run.\n` : ''}- If facts are missing, list them in unknowns; do not make them up.
71
103
  - report_markdown is a full markdown report with sections: “Response” (2-4 sentences),
72
104
  “How it works” (required: prose analysis ${questions.length ? 'for each sub-question' : 'of the mechanism'},
73
105
  grounded in the code), “Findings” (fact / location / confidence table), “Missing information”.`,
@@ -142,5 +174,11 @@ const languageRule = () =>
142
174
  `${RUN_ENV?.answerLanguage || DEFAULTS.answerLanguage}, whatever language the task is written in.`;
143
175
 
144
176
  export const INSTRUCTIONS = Object.fromEntries(
145
- Object.entries(BODIES).map(([agent, body]) => [agent, (...args) => `${body(...args)}\n${languageRule()}`]),
177
+ Object.entries(BODIES).map(([agent, body]) => [agent, (...args) => {
178
+ const opts = args[0] ?? {};
179
+ const instructions = body(...args);
180
+ const schema = agent === 'codex-advisor' ? advisorSchema(opts.phase) : schemaFor(agent);
181
+ const bounds = renderArrayBounds(schema);
182
+ return `${instructions}\n${languageRule()}${bounds ? `\n\n${bounds}` : ''}`;
183
+ }]),
146
184
  );
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Classifies orderer-marked scout questions and refuses malformed markers before quota is spent.
3
+ * Plan_60 D1: only the orderer knows whether a question concerns handed startup context or code;
4
+ * the scout cannot choose its own kind, and startup context currently fails without a command.
5
+ */
6
+ export const CONTEXT_ONLY_MARKER = '[context-only]';
7
+ export const QUESTION_KIND = Object.freeze({
8
+ STARTUP_CONTEXT: 'startup-context',
9
+ CODE_REQUIRED: 'code-required',
10
+ });
11
+
12
+ export function questionKindRefusal(texts = []) {
13
+ for (const [index, text] of texts.entries()) {
14
+ const tokens = text.matchAll(/\[\s*context[\s_-]*only\s*\]/gi);
15
+ for (const token of tokens) {
16
+ if (token.index !== 0 || token[0] !== CONTEXT_ONLY_MARKER) {
17
+ return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
18
+ }
19
+ }
20
+ if (text.startsWith(CONTEXT_ONLY_MARKER)) {
21
+ const question = text.slice(CONTEXT_ONLY_MARKER.length);
22
+ if (!/^\s/.test(question) || !question.trim()) {
23
+ return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function questionsFromTexts(texts = []) {
31
+ const refusal = questionKindRefusal(texts);
32
+ if (refusal) throw new Error(refusal);
33
+ return texts.map((text, index) => {
34
+ const marked = text.startsWith(CONTEXT_ONLY_MARKER);
35
+ return {
36
+ id: `Q${index + 1}`,
37
+ text: marked ? text.slice(CONTEXT_ONLY_MARKER.length).replace(/^\s+/, '') : text,
38
+ kind: marked ? QUESTION_KIND.STARTUP_CONTEXT : QUESTION_KIND.CODE_REQUIRED,
39
+ };
40
+ });
41
+ }
@@ -13,6 +13,7 @@
13
13
  * falling back — a typo must not decide what environment a run gets.
14
14
  */
15
15
  import { readRunConfig, disableFlags } from '../run-config.mjs';
16
+ import { EXIT } from './exit-codes.mjs';
16
17
 
17
18
  /**
18
19
  * The switches as read (they go to env.json) and the flags they turn into (they go to
@@ -33,6 +34,6 @@ export function loadRunEnv() {
33
34
  CLEAN_ENV = disableFlags(RUN_ENV);
34
35
  } catch (err) {
35
36
  console.log(`FAIL — ${err.message}\nRun was not started; quota was not spent`);
36
- process.exit(1);
37
+ process.exit(EXIT.FAIL);
37
38
  }
38
39
  }
@@ -85,7 +85,7 @@ export async function probeSandbox({
85
85
 
86
86
  const version = await attempt('version', ['--version']);
87
87
  if (version.error || version.signal || version.status !== 0) {
88
- // codexUnavailableReason() owns the refusal for an unavailable CLI, in the same pre-flight pass.
88
+ // codexAvailabilityRefusal() owns the refusal for an unavailable CLI, earlier in the same pre-flight pass.
89
89
  return finish('inconclusive', 'Codex CLI is unavailable.');
90
90
  }
91
91
  return finish('dead', 'The Codex sandbox on this host cannot start a process.');
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * The shape each agent's answer must have, one JSON schema per agent.
3
3
  * Plan_59 D10 adds alternatives, rated assumptions, risk outcomes, pre-mortems and open questions.
4
+ * Plan_68 D6 removes recommendation.text's hidden character cap after the 2026-09-27
5
+ * `…cli/install.mjs:` + U+0000 and 2026-09-28 `…(docs/plans/Plan_` + `恋` truncation incidents.
4
6
  *
5
7
  * Handed to `codex exec --output-schema`, so a run that answers in the wrong shape is
6
8
  * rejected by Codex itself rather than discovered later by write-meta.mjs. Keyed by agent
@@ -222,7 +224,8 @@ export const PHASE_SCHEMAS = {
222
224
  required: ['option_id', 'text'],
223
225
  properties: {
224
226
  option_id: { type: 'string' },
225
- text: { type: 'string', maxLength: 300, pattern: '^[^\\r\\n\\u2028\\u2029]*$' },
227
+ // Plan_68 D8: the 2026-10-01 runs hit control characters when prose line breaks were banned.
228
+ text: { type: 'string' },
226
229
  },
227
230
  },
228
231
  unlisted_option: { type: 'string' },
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { spawnSync } from 'node:child_process';
9
+ import { listRepositoryPaths } from './git-paths.mjs';
10
10
  import { globToRegExp, normalizePath, SERVICE_RE } from '../meta/paths.mjs';
11
11
 
12
12
  // Directories a self-walk must never descend into. Only reached when the repository has no git:
@@ -25,6 +25,13 @@ function absolutePattern(pattern) {
25
25
  }
26
26
 
27
27
  function structuralRefusal(repoRoot, pattern) {
28
+ // Plan_73 D7: scope lists store one pattern per line; literal line breaks cannot round-trip.
29
+ if (/[\n\r]/.test(pattern)) {
30
+ return {
31
+ reason: 'contains a newline or carriage return',
32
+ action: 'use a glob instead of a literal path containing a line break',
33
+ };
34
+ }
28
35
  if (absolutePattern(pattern)) {
29
36
  return {
30
37
  reason: 'is an absolute or drive-qualified path',
@@ -74,7 +81,18 @@ function structuralRefusal(repoRoot, pattern) {
74
81
  return null;
75
82
  }
76
83
 
77
- function noMatchRefusal(pattern) {
84
+ function noMatchRefusal(pattern, repoRoot, gitListed) {
85
+ // OW-042: order plan72-r3-b1-live-thresholds-20261001 ran from a folder outside git, the walk missed a tracked
86
+ // file, and the refusal claimed the file did not exist. A walked folder is not the repository git would list.
87
+ if (gitListed === false) {
88
+ return {
89
+ pattern,
90
+ reason: `does not match any existing path under ${repoRoot}; ` +
91
+ 'git could not list that folder as a repository, so it was walked instead',
92
+ action: 'start the run from the repository root (cd into it, or pass --repo <repository root>); ' +
93
+ 'if this folder is the intended one, correct the pattern',
94
+ };
95
+ }
78
96
  return {
79
97
  pattern,
80
98
  reason: 'does not match any existing path in the repository',
@@ -87,21 +105,6 @@ function repositoryPath(repoRoot, absolutePath) {
87
105
  return normalizePath(relative);
88
106
  }
89
107
 
90
- /**
91
- * The repository's own answer to "which files are there": tracked plus new-but-uncommitted, minus
92
- * everything ignored. It is the same boundary the run is judged by afterwards, it costs one process
93
- * instead of a full walk, and it cannot wander into `.git` or a dependency tree.
94
- */
95
- function gitPaths(repoRoot) {
96
- const result = spawnSync(
97
- 'git',
98
- ['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard'],
99
- { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
100
- );
101
- if (result.error || result.status !== 0) return null;
102
- return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
103
- }
104
-
105
108
  /** Walks a directory that has no git, skipping dependency trees and anything unreadable. */
106
109
  function walkPaths(repoRoot) {
107
110
  const pending = [repoRoot];
@@ -129,9 +132,14 @@ function walkPaths(repoRoot) {
129
132
  return found;
130
133
  }
131
134
 
135
+ /**
136
+ * Names come from git-paths.mjs so Plan_73's 2026-09-30 Cyrillic incident cannot recur through
137
+ * quoted git output or trimmed names. Its tracked-plus-untracked, non-ignored list costs one
138
+ * process instead of a walk and cannot wander into `.git` or a dependency tree.
139
+ */
132
140
  function matchingPaths(repoRoot, matchers) {
133
- const candidates = gitPaths(repoRoot)?.map((relative) => normalizePath(relative))
134
- ?? walkPaths(repoRoot);
141
+ const listed = listRepositoryPaths(repoRoot);
142
+ const candidates = listed ?? walkPaths(repoRoot);
135
143
  const matched = new Set();
136
144
  for (const candidate of candidates) {
137
145
  for (const matcher of matchers) {
@@ -141,12 +149,15 @@ function matchingPaths(repoRoot, matchers) {
141
149
  }
142
150
  if (matched.size === matchers.length) break;
143
151
  }
144
- return matched;
152
+ return { matched, gitListed: listed !== null };
145
153
  }
146
154
 
147
155
  function patternList(value, label) {
148
156
  if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
149
- return value.map((pattern) => String(pattern ?? '').trim());
157
+ return value.map((pattern) => {
158
+ const text = String(pattern ?? '');
159
+ return /[\n\r]/.test(text) ? text : text.trim();
160
+ });
150
161
  }
151
162
 
152
163
  /**
@@ -159,14 +170,18 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
159
170
  const declared = patternList(patterns, 'patterns');
160
171
  const newPaths = patternList(scopeNewPatterns, 'scopeNewPatterns');
161
172
  const allPatterns = [...declared, ...newPaths];
173
+ const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
162
174
 
175
+ // The flag is decided by --scope-new membership, not list position: args.mjs merges every --scope-new
176
+ // pattern into the declared list, so position named --scope for a directory given only to --scope-new
177
+ // (OW-042 B1, caught on the live runner 2026-10-04 after the unit test passed separate lists).
163
178
  for (const pattern of allPatterns) {
179
+ const flag = newPathKeys.has(normalizePath(pattern)) ? '--scope-new' : '--scope';
164
180
  const refusal = structuralRefusal(repoRoot, pattern);
165
- if (refusal) return { pattern, ...refusal };
166
- if (!pattern) return noMatchRefusal(pattern);
181
+ if (refusal) return { pattern, flag, ...refusal };
182
+ if (!pattern) return { ...noMatchRefusal(pattern), flag };
167
183
  }
168
184
 
169
- const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
170
185
  const required = allPatterns.filter((pattern) => !newPathKeys.has(normalizePath(pattern)));
171
186
  if (!required.length) return null;
172
187
 
@@ -175,13 +190,25 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
175
190
  matchers = required.map((pattern) => ({ pattern, regexp: globToRegExp(pattern) }));
176
191
  } catch (error) {
177
192
  return {
178
- pattern: required[0].pattern,
193
+ pattern: required[0],
194
+ flag: '--scope',
179
195
  reason: `could not parse the pattern: ${error.message}`,
180
196
  action: 'correct the pattern and retry',
181
197
  };
182
198
  }
183
199
 
184
- const matched = matchingPaths(repoRoot, matchers);
185
- const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
186
- return missing ? noMatchRefusal(missing.pattern) : null;
200
+ let matches;
201
+ try {
202
+ matches = matchingPaths(repoRoot, matchers);
203
+ } catch (error) {
204
+ if (error.code !== 'ERR_GIT_PATH_NOT_UTF8') throw error;
205
+ return {
206
+ pattern: required[0],
207
+ flag: '--scope',
208
+ reason: error.message,
209
+ action: 'rename the non-UTF-8 file using a UTF-8 file name and retry',
210
+ };
211
+ }
212
+ const missing = matchers.find((matcher) => !matches.matched.has(matcher.pattern));
213
+ return missing ? { ...noMatchRefusal(missing.pattern, repoRoot, matches.gitListed), flag: '--scope' } : null;
187
214
  }
@@ -8,9 +8,10 @@
8
8
  * questions in the file got refused for not passing --question.
9
9
  */
10
10
  import { die, readTaskDocument } from './args.mjs';
11
+ import { questionKindRefusal } from './question-kind.mjs';
11
12
 
12
13
  export function settleTaskInput(opts) {
13
- const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
+ const { task, header, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
15
  opts.questions ??= fileQuestions.length ? fileQuestions : undefined;
15
16
  opts.verify ??= fileVerify;
16
17
  // Still before the run folder exists and before a token of someone else's quota is touched,
@@ -22,5 +23,13 @@ export function settleTaskInput(opts) {
22
23
  'The runner will not infer questions from the task text; no quota was spent.',
23
24
  );
24
25
  }
25
- return task;
26
+ if (opts.agent === 'codex-scout') {
27
+ const refusal = questionKindRefusal(opts.questions);
28
+ if (refusal) {
29
+ die(
30
+ `${refusal}; the marker is exactly [context-only] followed by a space and the question; no quota was spent.`,
31
+ );
32
+ }
33
+ }
34
+ return { task, header };
26
35
  }