@lyupro/codex-bridge 0.6.11 → 0.6.14

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 (71) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +12 -9
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +58 -1
  5. package/cli/dispatcher-model-check.mjs +14 -12
  6. package/cli/doctor-host-side.mjs +117 -111
  7. package/cli/doctor-installation.mjs +132 -111
  8. package/cli/doctor-load-witness.mjs +75 -0
  9. package/cli/doctor-runs.mjs +41 -6
  10. package/cli/doctor-sandbox.mjs +64 -0
  11. package/cli/doctor.mjs +43 -18
  12. package/cli/guidance.mjs +42 -0
  13. package/cli/handback-witness-check.mjs +23 -19
  14. package/cli/host-inspection.mjs +1 -0
  15. package/cli/hosts.mjs +4 -3
  16. package/cli/install-record.mjs +9 -2
  17. package/cli/inventory-confirm.mjs +75 -0
  18. package/cli/inventory-transition.mjs +3 -1
  19. package/cli/manifest.mjs +6 -3
  20. package/cli/read.mjs +1 -2
  21. package/cli/removal-outcomes.mjs +5 -2
  22. package/cli/removal-plan.mjs +4 -1
  23. package/cli/remove-layout.mjs +4 -4
  24. package/cli/run-lookup.mjs +14 -2
  25. package/cli/runs-inventory.mjs +10 -2
  26. package/cli/runs-move-copy.mjs +123 -0
  27. package/cli/runs-move-history.mjs +78 -0
  28. package/cli/runs-move-remove.mjs +92 -0
  29. package/cli/runs-move.mjs +96 -0
  30. package/cli/stop.mjs +1 -2
  31. package/cli/uninstall-purge.mjs +1 -0
  32. package/cli/uninstall.mjs +2 -0
  33. package/cli/update.mjs +12 -7
  34. package/package.json +4 -2
  35. package/scripts/home-write-inventory.mjs +17 -2
  36. package/scripts/isolated-roots.mjs +2 -1
  37. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  38. package/src/{agents → claude/agents}/codex-build.md +7 -6
  39. package/src/{agents → claude/agents}/codex-review.md +6 -5
  40. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  41. package/src/{commands → claude/commands}/usage.md +9 -5
  42. package/src/claude/rules/core.md +29 -0
  43. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  44. package/src/home/guidance/advisor.md +42 -0
  45. package/src/home/guidance/budget.md +37 -0
  46. package/src/home/guidance/concurrency.md +17 -0
  47. package/src/home/guidance/order.md +66 -0
  48. package/src/home/guidance/records.md +32 -0
  49. package/src/home/hooks/live-runs.mjs +2 -1
  50. package/src/home/hooks/load-witness.mjs +23 -0
  51. package/src/home/lib/claude-layout.mjs +1 -0
  52. package/src/home/lib/codex-home.mjs +8 -0
  53. package/src/home/lib/home-registry.mjs +21 -0
  54. package/src/home/lib/hook-definitions.mjs +2 -0
  55. package/src/home/lib/host-version.mjs +33 -14
  56. package/src/home/lib/install-owner-roots.mjs +55 -53
  57. package/src/home/lib/load-witness.mjs +113 -0
  58. package/src/home/lib/meta/reply.mjs +1 -1
  59. package/src/home/lib/runner/codex-availability.mjs +11 -2
  60. package/src/home/lib/runner/exit-codes.mjs +1 -1
  61. package/src/home/lib/runner/launcher.mjs +10 -4
  62. package/src/home/lib/runner/preflight.mjs +12 -4
  63. package/src/home/lib/runner/retired-roots.mjs +81 -0
  64. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  65. package/src/home/lib/runner/runs-root.mjs +47 -7
  66. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  67. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  68. package/src/home/lib/runner/task-input.mjs +30 -0
  69. package/src/home/lib/runner/worker.mjs +5 -0
  70. /package/src/{commands → claude/commands}/env.md +0 -0
  71. /package/src/{commands → claude/commands}/model.md +0 -0
@@ -1,24 +1,43 @@
1
- /** Reads the host version from the transcript tail after the 2026-09-25 PATH host identity incident. */
1
+ /** Reads the host version from a session transcript after the 2026-09-25 PATH host identity incident. */
2
2
  import fs from 'node:fs/promises';
3
3
  import { parseJsonText } from './json-file.mjs';
4
4
 
5
- export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536 } = {}) {
5
+ const VERSION = /^\d+\.\d+\.\d+/;
6
+
7
+ function versionIn(transcriptPath, lines, sessionId, newestFirst) {
8
+ const order = newestFirst ? [...lines.keys()].reverse() : lines.keys();
9
+ for (const index of order) {
10
+ try {
11
+ const entry = parseJsonText(transcriptPath, lines[index]);
12
+ // Plan_64 D11: a neighboring session's version cannot identify this load witness.
13
+ if (sessionId && entry?.sessionId !== sessionId) continue;
14
+ if (typeof entry?.version === 'string' && VERSION.test(entry.version)) return entry.version;
15
+ } catch {}
16
+ }
17
+ return null;
18
+ }
19
+
20
+ async function slice(handle, position, length) {
21
+ const buffer = Buffer.alloc(length);
22
+ await handle.read(buffer, 0, length, position);
23
+ return buffer.toString('utf8').split(/\r?\n/);
24
+ }
25
+
26
+ /**
27
+ * A live session's newest lines name its host, so the tail answers the shell-command observers. A finished session
28
+ * may end in a 135 KB attachment line and metadata without `version` (B6b live run, 2026-10-10), so a named session is
29
+ * also looked up from the head: one session runs on one host, and its first user line already carries the version.
30
+ */
31
+ export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536, headBytes = 1048576, sessionId } = {}) {
32
+ const session = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
6
33
  try {
7
34
  const handle = await fs.open(transcriptPath, 'r');
8
35
  try {
9
36
  const { size } = await handle.stat();
10
- const length = Math.min(size, tailBytes);
11
- const buffer = Buffer.alloc(length);
12
- await handle.read(buffer, 0, length, size - length);
13
- const lines = buffer.toString('utf8').split(/\r?\n/);
14
- for (let index = lines.length - 1; index >= 0; index -= 1) {
15
- try {
16
- const entry = parseJsonText(transcriptPath, lines[index]);
17
- if (typeof entry?.version === 'string' && /^\d+\.\d+\.\d+/.test(entry.version)) {
18
- return entry.version;
19
- }
20
- } catch {}
21
- }
37
+ const tailLength = Math.min(size, tailBytes);
38
+ const fromTail = versionIn(transcriptPath, await slice(handle, size - tailLength, tailLength), session, true);
39
+ if (fromTail !== null || !session) return fromTail;
40
+ return versionIn(transcriptPath, await slice(handle, 0, Math.min(size, headBytes)), session, false);
22
41
  } finally {
23
42
  await handle.close();
24
43
  }
@@ -1,53 +1,55 @@
1
- /**
2
- * Reads which host roots own this home from the installation record, read-only, for hooks.
3
- * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
- */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { homeArtifact } from './home-registry.mjs';
8
- import { parseJsonText } from './json-file.mjs';
9
-
10
- const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
-
12
- export function isFormat2(parsed) {
13
- return isObject(parsed) && parsed.format === 2;
14
- }
15
-
16
- export function readOwnerRoots({ brandRoot }) {
17
- const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
- let raw;
19
- try {
20
- raw = fs.readFileSync(file, 'utf8');
21
- } catch (error) {
22
- return error.code === 'ENOENT'
23
- ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
- : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
- }
26
-
27
- let parsed;
28
- try {
29
- parsed = parseJsonText(file, raw);
30
- } catch {
31
- return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
- }
33
- if (!isFormat2(parsed)) {
34
- return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
- }
36
- if (parsed.inventory !== 'complete') {
37
- return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory.` };
38
- }
39
- if (Object.hasOwn(parsed, 'legacy')) {
40
- return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
41
- }
42
- if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
43
- return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
44
- }
45
- const roots = [];
46
- for (const owner of Object.values(parsed.owners)) {
47
- if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
48
- return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
49
- }
50
- roots.push(owner.root);
51
- }
52
- return { roots: roots.sort() };
53
- }
1
+ /**
2
+ * Reads which host roots own this home from the installation record, read-only, for hooks.
3
+ * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { homeArtifact } from './home-registry.mjs';
8
+ import { parseJsonText } from './json-file.mjs';
9
+
10
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ export function isFormat2(parsed) {
13
+ return isObject(parsed) && parsed.format === 2;
14
+ }
15
+
16
+ export function readOwnerRoots({ brandRoot }) {
17
+ const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
+ let raw;
19
+ try {
20
+ raw = fs.readFileSync(file, 'utf8');
21
+ } catch (error) {
22
+ return error.code === 'ENOENT'
23
+ ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
+ : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
+ }
26
+
27
+ let parsed;
28
+ try {
29
+ parsed = parseJsonText(file, raw);
30
+ } catch {
31
+ return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
+ }
33
+ if (!isFormat2(parsed)) {
34
+ return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
+ }
36
+ // Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
37
+ if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
38
+ return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
39
+ }
40
+ if (parsed.inventory !== 'complete') {
41
+ // Plan_67 D11: match cli/inventory-confirm.mjs INVENTORY_CONFIRM_COMMAND; hooks cannot import cli/.
42
+ return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory; if the recorded hosts are all the hosts using this home, run codex-bridge inventory confirm.` };
43
+ }
44
+ if (Object.hasOwn(parsed, 'legacy')) {
45
+ return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
46
+ }
47
+ const roots = [];
48
+ for (const owner of Object.values(parsed.owners)) {
49
+ if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
50
+ return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
51
+ }
52
+ roots.push(owner.root);
53
+ }
54
+ return { roots: roots.sort() };
55
+ }
@@ -0,0 +1,113 @@
1
+ /** Records the host's rules-core loading evidence (Plan_64 D11), without resolving owners or versions. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
6
+ import { withHomeFileLock } from './file-lock.mjs';
7
+ import { stateDirWriter } from './home-write.mjs';
8
+ import { parseJsonText } from './json-file.mjs';
9
+
10
+ export const LOAD_WITNESS_FILE = 'load-witness.json';
11
+ // Plan_64 D15 / review B8: larger files cannot be the package core; each UTF-16 unit needs at most 3 UTF-8 bytes.
12
+ export const CORE_CHARACTER_CEILING = 2400;
13
+ export const CORE_BYTE_CEILING = 3 * CORE_CHARACTER_CEILING;
14
+ const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
15
+
16
+ function readBoundedCoreBytes(filePath) {
17
+ const buffer = Buffer.alloc(CORE_BYTE_CEILING + 1);
18
+ const descriptor = fs.openSync(filePath, 'r');
19
+ try {
20
+ let length = 0;
21
+ while (length < buffer.length) {
22
+ const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null);
23
+ if (count === 0) return buffer.subarray(0, length);
24
+ length += count;
25
+ }
26
+ return null;
27
+ } finally {
28
+ fs.closeSync(descriptor);
29
+ }
30
+ }
31
+
32
+ export function readLoadWitness({ stateDir }) {
33
+ const file = path.join(stateDir, LOAD_WITNESS_FILE);
34
+ let raw;
35
+ try {
36
+ raw = fs.readFileSync(file, 'utf8');
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') return { sessions: {} };
39
+ return { sessions: {}, corrupt: true };
40
+ }
41
+ try {
42
+ const record = parseJsonText(file, raw);
43
+ if (!record || typeof record !== 'object' || Array.isArray(record)
44
+ || !record.sessions || typeof record.sessions !== 'object' || Array.isArray(record.sessions)) {
45
+ return { sessions: {}, corrupt: true };
46
+ }
47
+ return { sessions: record.sessions };
48
+ } catch {
49
+ return { sessions: {}, corrupt: true };
50
+ }
51
+ }
52
+
53
+ export function isCoreLoadCandidate(filePath) {
54
+ return typeof filePath === 'string'
55
+ && filePath.replaceAll('\\', '/').toLowerCase().endsWith('/rules/codex-bridge/core.md');
56
+ }
57
+
58
+ export async function recordCoreLoad({ stateDir, input, now = new Date(), readCore = readBoundedCoreBytes }) {
59
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
60
+ return { recorded: false, reason: 'invalid-input' };
61
+ }
62
+ if (input.hook_event_name !== 'InstructionsLoaded') return { recorded: false, reason: 'other-event' };
63
+ if (input.load_reason !== 'session_start') return { recorded: false, reason: 'other-load-reason' };
64
+ if (typeof input.session_id !== 'string' || !input.session_id.trim()) {
65
+ return { recorded: false, reason: 'missing-session-id' };
66
+ }
67
+ if (typeof input.file_path !== 'string' || !input.file_path.trim()) {
68
+ return { recorded: false, reason: 'missing-file-path' };
69
+ }
70
+ if (!isCoreLoadCandidate(input.file_path)) return { recorded: false, reason: 'other-file' };
71
+
72
+ let sha256 = null;
73
+ let readError;
74
+ let hashSkippedReason;
75
+ try {
76
+ const bytes = readCore(input.file_path);
77
+ if (bytes === null) hashSkippedReason = 'core-too-large';
78
+ else sha256 = createHash('sha256').update(bytes).digest('hex');
79
+ } catch (error) {
80
+ readError = error.code ?? null;
81
+ }
82
+ const file = path.join(stateDir, LOAD_WITNESS_FILE);
83
+ const writer = stateDirWriter(stateDir);
84
+ return withHomeFileLock(writer, 'load-witness', `${file}.lock`, async () => {
85
+ const current = readLoadWitness({ stateDir });
86
+ if (current.corrupt) throw new Error(`Cannot update corrupt load witness: ${file}`);
87
+ const at = new Date(now).toISOString();
88
+ const cutoff = Date.parse(at) - RETENTION_MS;
89
+ // A hand-edited entry without a loads array is dropped, not allowed to throw: one bad row would
90
+ // otherwise lose every later witness, and a lost witness reads as "not verified" forever (D11).
91
+ const sessions = Object.fromEntries(Object.entries(current.sessions).filter(([, entry]) => {
92
+ if (!Array.isArray(entry?.loads)) return false;
93
+ const newest = Math.max(...entry.loads.map((load) => Date.parse(load?.at)));
94
+ return newest >= cutoff;
95
+ }));
96
+ const session = Object.hasOwn(sessions, input.session_id)
97
+ ? sessions[input.session_id] : { loads: [] };
98
+ const duplicate = session.loads.some((load) => load.path === input.file_path);
99
+ if (!duplicate) {
100
+ session.loads.push({
101
+ path: input.file_path, loadReason: input.load_reason, memoryType: input.memory_type ?? null,
102
+ sha256, at, ...(readError !== undefined ? { readError } : {}),
103
+ ...(hashSkippedReason !== undefined ? { hashSkippedReason } : {}),
104
+ });
105
+ }
106
+ session.transcriptPath ??= input.transcript_path;
107
+ session.cwd ??= input.cwd;
108
+ // Defining the key preserves host-provided session ids such as __proto__ as ordinary data.
109
+ Object.defineProperty(sessions, input.session_id, { value: session, enumerable: true, configurable: true, writable: true });
110
+ writeHomeJsonAtomic(writer, 'load-witness', file, { sessions });
111
+ return duplicate ? { recorded: false, reason: 'duplicate-path' } : { recorded: true };
112
+ }, { description: 'load witness' });
113
+ }
@@ -238,7 +238,7 @@ export function limitReply(ctx, meta) {
238
238
  /** Plan_60 D2: pre-start and post-start refusals share the same handoff contract. */
239
239
  export function unavailableRows(signal) {
240
240
  return [
241
- 'UNAVAILABLE — Codex is not available (missing or signed out); ' +
241
+ 'UNAVAILABLE — Codex is not available (signed out); ' +
242
242
  'hand the task to the next executor, do not retry',
243
243
  `Signal: ${line(signal, 170)}`,
244
244
  'Operator check: codex --version; codex login status (sign in with codex login)',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Decides from conclusive evidence only whether Codex is missing, signed out, available, or cannot be judged.
2
+ * Decides from conclusive evidence only whether Codex is signed out, available, or cannot be judged.
3
3
  * Plan_60 D2: an inconclusive probe must never become "do not retry".
4
4
  */
5
5
  import { resolveCommandOnPath } from '../command-path.mjs';
@@ -21,10 +21,19 @@ function failureDetail(result, command) {
21
21
  export async function probeCodexAvailability({
22
22
  env = process.env, platform = process.platform,
23
23
  run = spawnCaptured, resolve = resolveCommandOnPath, timeoutMs = PROBE_TIMEOUT_MS,
24
+ retryDelayMs = 1_000, delay = (ms) => new Promise((done) => setTimeout(done, ms)),
24
25
  } = {}) {
25
26
  try {
26
27
  if (resolve('codex', env) === null) {
27
- return { state: 'missing', detail: 'codex is not on PATH' };
28
+ // Plan_78 D5: the 2026-10-10 TradeForge refusal during an npm update showed that
29
+ // a transient PATH miss cannot prove absence; retry once before leaving readiness unconfirmed.
30
+ await delay(retryDelayMs);
31
+ if (resolve('codex', env) === null) {
32
+ return {
33
+ state: 'inconclusive', pathMiss: true,
34
+ detail: 'codex could not be resolved in this process PATH; readiness is unconfirmed',
35
+ };
36
+ }
28
37
  }
29
38
  const capture = (args) => {
30
39
  const spec = codexSpawnSpec(args, platform);
@@ -9,5 +9,5 @@ export const EXIT = Object.freeze({
9
9
  USAGE: 2, // argument error: the order itself must be rewritten
10
10
  LIMIT: 3, // ChatGPT quota exhausted; not a task failure
11
11
  PENDING: 4, // --no-wait only: the run is still in progress, or no run exists (a call outcome, not a status)
12
- UNAVAILABLE: 5, // Codex missing or signed out; hand the task to the next executor
12
+ UNAVAILABLE: 5, // Codex signed out (a PATH miss is a free refusal, Plan_78 D5); hand the task to the next executor
13
13
  });
@@ -33,7 +33,7 @@ import { agentRole } from '../agents.mjs';
33
33
  import { codexArgs, runProfile } from './codex-args.mjs';
34
34
  import { writeWorkerOrder } from './worker-order.mjs';
35
35
  import { unsafeForCmd } from './codex-cmd.mjs';
36
- import { runsRoot } from './runs-root.mjs';
36
+ import { runsRootResolution, staleOverrideRefusal } from './runs-root.mjs';
37
37
  import { resolveProjectRunsDir } from './project-dir.mjs';
38
38
  import { cleanupRetention } from '../retention.mjs';
39
39
  import { renderConventions } from './conventions.mjs';
@@ -95,8 +95,9 @@ export async function launcher(argv = process.argv.slice(2)) {
95
95
  const opts = parseArgs(argv);
96
96
  const { task: taskText, header } = settleTaskInput(opts);
97
97
  opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
98
+ const resolution = runsRootResolution();
98
99
  // Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
99
- const taskGate = taskPreflight({ agent: opts.agent, taskText, header });
100
+ const taskGate = taskPreflight({ agent: opts.agent, taskText, header, resolution });
100
101
  if (taskGate.refusal) die(taskGate.refusal, EXIT.FAIL);
101
102
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
102
103
  const isGitRepo = topLevel.status === 0;
@@ -113,7 +114,11 @@ export async function launcher(argv = process.argv.slice(2)) {
113
114
  `Action: ${scopeRefusal.action}. The run folder was not created; quota was not spent.`,
114
115
  );
115
116
  }
116
- const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
117
+ // Plan_77 D6: project-dir.mjs:84 recursively recreated a retired CODEX_RUNS_ROOT.
118
+ // Refuse before that mkdir, using the same resolution that checked the advice header.
119
+ const stale = staleOverrideRefusal(resolution);
120
+ if (stale) die(`${stale}\nThe run folder was not created; quota was not spent.`, EXIT.FAIL);
121
+ const projectRunsRoot = resolveProjectRunsDir(resolution.root, repoRoot).dir;
117
122
  // Plan_60 D4c, 2026-09-24: two launchers of one order both billed. The claim comes before markAbandoned, or a
118
123
  // contender could close a run whose worker is admitting itself and start a second paid one.
119
124
  const claim = await acquireOrderClaim({ projectRunsRoot, orderId: opts.orderId, role: 'launcher' });
@@ -141,7 +146,8 @@ export async function launcher(argv = process.argv.slice(2)) {
141
146
  if ('exitCode' in gate) return gate.exitCode;
142
147
  const { taskHash, chain, startedChain, continuationGrant, advisorTask, retryOf } = gate;
143
148
 
144
- // Plan_60 D2: a missing or signed-out Codex answers UNAVAILABLE (exit 5) before the paid sandbox probe.
149
+ // Plan_60 D2: a signed-out Codex answers UNAVAILABLE (exit 5) before the paid sandbox probe;
150
+ // a PATH miss, after one retry, is the free readiness-unconfirmed refusal (exit 1, Plan_78 D5).
145
151
  const availability = await codexAvailabilityRefusal();
146
152
  if (availability?.unavailable) { process.stdout.write(`${availability.text}\n`); return EXIT.UNAVAILABLE; }
147
153
  if (availability) die(availability.text, EXIT.FAIL);
@@ -12,13 +12,17 @@ import { probeCodexAvailability } from './codex-availability.mjs';
12
12
  import { agentRole } from '../agents.mjs';
13
13
  import { die } from './args.mjs';
14
14
  import { orderInputName } from '../order-schema.mjs';
15
+ import { retiredPathRefusal, runsRootResolution } from './runs-root.mjs';
15
16
 
16
- /** Plan_60 D2: only conclusive evidence says UNAVAILABLE; an unclear probe stays an ordinary refusal. */
17
+ /** Plan_60 D2: only conclusive evidence says UNAVAILABLE; a PATH miss is inconclusive (Plan_78 D5). */
17
18
  export async function codexAvailabilityRefusal({ probe = probeCodexAvailability } = {}) {
18
19
  const result = await probe();
19
20
  if (result.state === 'available') return null;
20
- const unavailable = result.state === 'missing' || result.state === 'logged-out';
21
- const rows = unavailable ? unavailableRows(result.detail) : [
21
+ const unavailable = result.state === 'logged-out';
22
+ const rows = unavailable ? unavailableRows(result.detail) : result.pathMiss === true ? [
23
+ `Codex CLI readiness unconfirmed: ${result.detail}`,
24
+ 'Do not skip this bridge: check codex --version for up to about 2 minutes; when it answers, repeat the same order exactly once; if it does not answer, hand the order to the next executor and name that in the summary.',
25
+ ] : [
22
26
  `Codex CLI unavailable: ${result.detail}`,
23
27
  'Operator check: codex --version (and codex login if authorization is rejected)',
24
28
  ];
@@ -29,7 +33,7 @@ export async function codexAvailabilityRefusal({ probe = probeCodexAvailability
29
33
  }
30
34
 
31
35
  /** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
32
- export function taskPreflight({ agent, taskText, header }) {
36
+ export function taskPreflight({ agent, taskText, header, resolution = runsRootResolution() }) {
33
37
  const freeRefusal = (reason) => ({
34
38
  refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
35
39
  });
@@ -44,6 +48,10 @@ export function taskPreflight({ agent, taskText, header }) {
44
48
  return { refusal: null, advice };
45
49
  }
46
50
  if (path.isAbsolute(advice)) {
51
+ // Plan_77 D6: preflight.mjs:46-58 swallowed the old-path read failure as missing advice.
52
+ // Diagnose before stat/read so neither existing nor absent retired evidence is opened.
53
+ const retired = retiredPathRefusal(advice, resolution);
54
+ if (retired) return freeRefusal(retired);
47
55
  try {
48
56
  // Plan_59 D26: only a judged advice authorizes a construction. On 2026-09-24 the gate took
49
57
  // any codex-advisor folder, so a scope run or a FAIL advice would have passed as a second opinion.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Knows the run roots the records moved away from and says where a path under one of them lives now.
3
+ *
4
+ * Plan_77's “Why” paragraph: on 2026-10-07 thousands of run records in another project's
5
+ * git tree made a VaultForge journal compile cost $16.57 instead of $0.3-0.8. D6 forbids
6
+ * redirect links or silent remapping; a corrupt move record must never revive that store.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { writeHomeJsonAtomic } from '../atomic-json.mjs';
11
+ import { stateDirWriter } from '../home-write.mjs';
12
+ import { parseJsonText } from '../json-file.mjs';
13
+ import { normalizeRepoPath } from './project-dir.mjs';
14
+
15
+ export const RUNS_MOVE_RECORD = 'runs-root.json';
16
+
17
+ export function readRunsMoveRecord(stateDir) {
18
+ const file = path.join(stateDir, RUNS_MOVE_RECORD);
19
+ let text;
20
+ try {
21
+ text = fs.readFileSync(file, 'utf8');
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw new Error(`Cannot read runs move record ${file}: ${error.message}`, { cause: error });
25
+ }
26
+ let record;
27
+ try {
28
+ record = parseJsonText(file, text);
29
+ } catch (error) {
30
+ throw new Error(`Invalid runs move record ${file}: ${error.message}`, { cause: error });
31
+ }
32
+ const invalid = (defect) => { throw new Error(`Invalid runs move record ${file}: ${defect}`); };
33
+ if (record?.version !== 1) invalid('version must be 1');
34
+ if (!Array.isArray(record.retired) || record.retired.length === 0) {
35
+ invalid('retired must be a non-empty array');
36
+ }
37
+ for (const [index, entry] of record.retired.entries()) {
38
+ if (typeof entry?.root !== 'string' || !path.isAbsolute(entry.root)) {
39
+ invalid(`retired[${index}].root must be an absolute path`);
40
+ }
41
+ if (typeof entry.movedAt !== 'string' || !Number.isFinite(Date.parse(entry.movedAt))) {
42
+ invalid(`retired[${index}].movedAt must be a parseable ISO time`);
43
+ }
44
+ }
45
+ return record;
46
+ }
47
+
48
+ export function retiredRootOf(candidate, retiredRoots) {
49
+ if (typeof candidate !== 'string' || candidate.length === 0) return null;
50
+ const normalized = normalizeRepoPath(candidate);
51
+ for (const { root } of retiredRoots) {
52
+ const retired = normalizeRepoPath(root);
53
+ if (normalized === retired) return { root, suffix: '' };
54
+ if (normalized.startsWith(`${retired}/`)) {
55
+ // Compare normalized paths, but keep the candidate's segment spelling for the advice. Cut by
56
+ // segment count, not by string length: lower-casing can change the length of a non-ASCII name.
57
+ const spelled = path.resolve(candidate).replaceAll('\\', '/').replace(/\/$/, '').split('/');
58
+ return { root, suffix: spelled.slice(retired.split('/').length).join('/') };
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function retiredRootRefusal({ candidate, retired, destination }) {
65
+ const equivalent = retired.suffix ? path.join(destination, retired.suffix) : destination;
66
+ return `Run records moved from ${retired.root} to ${destination}. Equivalent path: ${equivalent}. ` +
67
+ 'Update advice: or pass this new path explicitly. No path was remapped.';
68
+ }
69
+
70
+ /**
71
+ * Plan_77 D7: the move record is written last, by the move command, after the copy is verified — its
72
+ * presence is what switches the resolver. A later move appends to the list instead of replacing it, so
73
+ * every root the records ever left keeps answering with a refusal rather than an empty lookup.
74
+ */
75
+ export function writeRunsMoveRecord(stateDir, { root, movedAt = new Date().toISOString() }) {
76
+ const previous = readRunsMoveRecord(stateDir);
77
+ const retired = [...(previous?.retired ?? []), { root, movedAt }];
78
+ const record = { version: 1, retired };
79
+ writeHomeJsonAtomic(stateDirWriter(stateDir), 'runs-move-record', path.join(stateDir, RUNS_MOVE_RECORD), record);
80
+ return record;
81
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Commits a finished run's folder when the run store is a git repository of its own.
3
+ *
4
+ * Plan_77 D1/D5: on 2026-10-07 thousands of uncommitted runs in ~/.claude made a
5
+ * VaultForge journal compile cost $16.57 instead of $0.3-0.8. Commit only in the
6
+ * store's own repository, preserve the operator's staged paths, and never remove index.lock.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { resolveBrandHome } from '../brand-home.mjs';
12
+ import { readJsonFileSync } from '../json-file.mjs';
13
+ import { normalizeRepoPath, PROJECT_MARKER } from './project-dir.mjs';
14
+
15
+ function runGit(cwd, args) {
16
+ return spawnSync('git', ['-C', cwd, ...args], {
17
+ encoding: 'utf8', windowsHide: true, timeout: 120_000,
18
+ });
19
+ }
20
+
21
+ function waitSync(milliseconds) {
22
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
23
+ }
24
+
25
+ const QUIET_REASONS = new Set(['not in git', 'repository is not the run store', 'nothing to commit']);
26
+
27
+ export function commitRunRecord(options) {
28
+ let runDir;
29
+ try {
30
+ let brandRoot, git, sleep, attempts, waitMs;
31
+ ({ runDir, brandRoot = resolveBrandHome().root, git = runGit,
32
+ sleep = waitSync, attempts = 10, waitMs = 500 } = options);
33
+ const storeRoot = path.dirname(path.dirname(runDir));
34
+ const step = (cwd, args) => {
35
+ for (let attempt = 1; ; attempt += 1) {
36
+ let result;
37
+ try {
38
+ result = git(cwd, args);
39
+ } catch (error) {
40
+ result = { status: null, stderr: error.message, error };
41
+ }
42
+ const locked = String(result.stderr).includes('index.lock');
43
+ if (locked && attempt < attempts) {
44
+ sleep(waitMs);
45
+ continue;
46
+ }
47
+ return { ...result, locked };
48
+ }
49
+ };
50
+ const failure = (name, result) => {
51
+ const firstLine = String(result.stderr || result.error?.message || '').trim().split(/\r?\n/)[0];
52
+ return { committed: false,
53
+ reason: `git ${name} failed (exit ${result.status ?? 'unknown'}): ${firstLine}` };
54
+ };
55
+ const perform = () => {
56
+ const repo = step(storeRoot, ['rev-parse', '--show-toplevel']);
57
+ if (repo.locked || repo.status !== 0) {
58
+ return !repo.locked && /not a git repository/i.test(repo.stderr || '')
59
+ ? { committed: false, reason: 'not in git' } : failure('rev-parse', repo);
60
+ }
61
+ const top = repo.stdout.trim();
62
+ const normalizedTop = normalizeRepoPath(top);
63
+ const normalizedStore = normalizeRepoPath(storeRoot);
64
+ const normalizedBrand = normalizeRepoPath(brandRoot);
65
+ if (normalizedTop !== normalizedStore && !(normalizedTop === normalizedBrand &&
66
+ normalizedStore.startsWith(`${normalizedBrand}/`))) {
67
+ return { committed: false, reason: 'repository is not the run store' };
68
+ }
69
+
70
+ const relative = (file) => path.relative(top, file).replaceAll(path.sep, '/');
71
+ const paths = [relative(runDir)];
72
+ const marker = path.join(path.dirname(runDir), PROJECT_MARKER);
73
+ if (fs.existsSync(marker)) paths.push(relative(marker));
74
+ const added = step(top, ['add', '-A', '--', ...paths]);
75
+ if (added.locked || added.status !== 0) return failure('add', added);
76
+ const diff = step(top, ['diff', '--cached', '--quiet', '--', ...paths]);
77
+ if (diff.locked) return failure('diff', diff);
78
+ if (diff.status === 0) return { committed: false, reason: 'nothing to commit' };
79
+ if (diff.status !== 1) return failure('diff', diff);
80
+
81
+ let status = 'unknown';
82
+ try {
83
+ status = readJsonFileSync(path.join(runDir, 'meta.json'))?.status ?? 'unknown';
84
+ } catch {
85
+ // An unreadable verdict must not prevent preserving the finished record (Plan_77 B6).
86
+ }
87
+ const message = `${path.basename(path.dirname(runDir))}/${path.basename(runDir)}: ${status}`;
88
+ // D5: the pathspec keeps every unrelated staged entry out of this commit and in the index.
89
+ const committed = step(top, ['commit', '--quiet', '-m', message, '--', ...paths]);
90
+ return !committed.locked && committed.status === 0
91
+ ? { committed: true, reason: 'committed' } : failure('commit', committed);
92
+ };
93
+ const result = perform();
94
+ if (!result.committed && !QUIET_REASONS.has(result.reason)) appendFailure(runDir, result.reason);
95
+ return result;
96
+ } catch (error) {
97
+ const reason = `autocommit failed: ${String(error?.message || error).split(/\r?\n/)[0]}`;
98
+ appendFailure(runDir, reason);
99
+ return { committed: false, reason };
100
+ }
101
+ }
102
+
103
+ function appendFailure(runDir, reason) {
104
+ try {
105
+ fs.appendFileSync(path.join(runDir, 'stderr.log'), `autocommit: ${reason}\n`);
106
+ } catch {
107
+ // Even an unwritable diagnostic log cannot change the run's verdict (Plan_77 B6).
108
+ }
109
+ }