@lyupro/codex-bridge 0.6.12 → 0.6.15

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 (66) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +10 -8
  3. package/cli/command-registry.mjs +59 -18
  4. package/cli/doctor-installation.mjs +132 -111
  5. package/cli/doctor-load-witness.mjs +75 -0
  6. package/cli/doctor-runs.mjs +41 -6
  7. package/cli/doctor-sandbox.mjs +64 -0
  8. package/cli/doctor.mjs +26 -8
  9. package/cli/guidance.mjs +42 -0
  10. package/cli/host-inspection.mjs +1 -0
  11. package/cli/hosts.mjs +4 -3
  12. package/cli/install-record.mjs +4 -2
  13. package/cli/manifest.mjs +6 -3
  14. package/cli/read.mjs +1 -2
  15. package/cli/removal-outcomes.mjs +5 -2
  16. package/cli/removal-plan.mjs +4 -1
  17. package/cli/remove-layout.mjs +4 -4
  18. package/cli/run-lookup.mjs +14 -2
  19. package/cli/runs-inventory.mjs +10 -2
  20. package/cli/runs-move-copy.mjs +123 -0
  21. package/cli/runs-move-history.mjs +78 -0
  22. package/cli/runs-move-remove.mjs +92 -0
  23. package/cli/runs-move.mjs +96 -0
  24. package/cli/stop.mjs +1 -2
  25. package/cli/uninstall-purge.mjs +1 -0
  26. package/cli/uninstall.mjs +2 -0
  27. package/cli/update.mjs +12 -7
  28. package/package.json +4 -2
  29. package/scripts/home-write-inventory.mjs +17 -2
  30. package/scripts/isolated-roots.mjs +2 -1
  31. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  32. package/src/{agents → claude/agents}/codex-build.md +7 -6
  33. package/src/{agents → claude/agents}/codex-review.md +6 -5
  34. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  35. package/src/{commands → claude/commands}/usage.md +9 -5
  36. package/src/claude/rules/core.md +29 -0
  37. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  38. package/src/home/guidance/advisor.md +42 -0
  39. package/src/home/guidance/budget.md +37 -0
  40. package/src/home/guidance/concurrency.md +17 -0
  41. package/src/home/guidance/order.md +68 -0
  42. package/src/home/guidance/records.md +32 -0
  43. package/src/home/hooks/live-runs.mjs +2 -1
  44. package/src/home/hooks/load-witness.mjs +23 -0
  45. package/src/home/lib/claude-layout.mjs +1 -0
  46. package/src/home/lib/codex-home.mjs +8 -0
  47. package/src/home/lib/home-registry.mjs +21 -0
  48. package/src/home/lib/hook-definitions.mjs +2 -0
  49. package/src/home/lib/host-version.mjs +33 -14
  50. package/src/home/lib/load-witness.mjs +113 -0
  51. package/src/home/lib/meta/paths.mjs +6 -1
  52. package/src/home/lib/meta/reply.mjs +1 -1
  53. package/src/home/lib/runner/codex-availability.mjs +11 -2
  54. package/src/home/lib/runner/exit-codes.mjs +1 -1
  55. package/src/home/lib/runner/launcher.mjs +10 -4
  56. package/src/home/lib/runner/preflight.mjs +12 -4
  57. package/src/home/lib/runner/prompts.mjs +2 -2
  58. package/src/home/lib/runner/retired-roots.mjs +81 -0
  59. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  60. package/src/home/lib/runner/runs-root.mjs +47 -7
  61. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  62. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  63. package/src/home/lib/runner/task-input.mjs +30 -0
  64. package/src/home/lib/runner/worker.mjs +5 -0
  65. /package/src/{commands → claude/commands}/env.md +0 -0
  66. /package/src/{commands → claude/commands}/model.md +0 -0
@@ -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
+ }
@@ -89,7 +89,12 @@ export const changedPaths = (before, after) => {
89
89
  };
90
90
 
91
91
  /** Directories a delegated build has no business editing, whatever the task says. */
92
- export const SERVICE_RE = /^(\.omx|\.omc|\.claude|\.codex|\.git|node_modules)\//;
92
+ export const SERVICE_FOLDERS = Object.freeze([
93
+ '.omx', '.omc', '.claude', '.codex', '.git', 'node_modules',
94
+ ]);
95
+ export const SERVICE_RE = new RegExp(
96
+ `^(${SERVICE_FOLDERS.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/`,
97
+ );
93
98
 
94
99
  /**
95
100
  * Paths arrive from two sides that spell them differently: git prints repo-relative with
@@ -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.
@@ -8,6 +8,7 @@
8
8
  * (`…cli/install.mjs:` + U+0000) and 2026-09-28 (`…(docs/plans/Plan_` + `恋`).
9
9
  */
10
10
  import { DEFAULTS } from '../run-config.mjs';
11
+ import { SERVICE_FOLDERS } from '../meta/paths.mjs';
11
12
  import { RUN_ENV } from './run-env.mjs';
12
13
  import { advisorSchema, schemaFor } from './schemas.mjs';
13
14
  import { QUESTION_KIND } from './question-kind.mjs';
@@ -140,8 +141,7 @@ Working rules:
140
141
  (\`dir/{a,b}.ts\`), and lists separated by \`;\` or \`,\` are prohibited: a collapsed entry will
141
142
  match no file when the report is checked against the worktree and will fail the run even if
142
143
  the work was done honestly.
143
- - Do not fix the environment. Service directories (\`.omx/\`, \`.claude/\`, \`.codex/\`, \`.git/\`,
144
- \`node_modules/\`) and other sessions are not your task, even if they prevent you from working
144
+ - Do not fix the environment. Service directories (${SERVICE_FOLDERS.map((name) => `\`${name}/\``).join(', ')}) and other sessions are not your task, even if they prevent you from working
145
145
  or finishing. Put the obstacle in leftovers instead of changing them.
146
146
  - report_markdown is a full markdown report with sections: “What was done”, “Changes”
147
147
  (file / what changed / why table), “Verification” (command and its actual output),
@@ -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
+ }
@@ -1,16 +1,56 @@
1
1
  /**
2
- * Says where run artifacts live.
2
+ * One resolver says where run artifacts live: CODEX_RUNS_ROOT, moved, legacy, or default.
3
3
  *
4
- * The path used to be built from os.homedir() in two places, which taught the package that it
5
- * lives inside ~/.claude — outside that folder it had nowhere to write. The value is read on
6
- * every call, not frozen into a constant, because tests override the variable per case.
4
+ * Plan_77 D7 ships code before the move: an existing legacy directory stays active until
5
+ * the move record exists; a clean installation uses the package home immediately. Records
6
+ * in another project's git tree cost a VaultForge journal compile $16.57 instead of $0.3-0.8
7
+ * on 2026-10-07. The value is read on every call, not frozen into a constant, because tests
8
+ * override the variable per case and a verified move must take effect without a restart.
7
9
  */
10
+ import fs from 'node:fs';
8
11
  import os from 'node:os';
9
12
  import path from 'node:path';
13
+ import { resolveBrandHome } from '../brand-home.mjs';
14
+ import { RUN_RECORDS_DIR } from '../home-registry.mjs';
15
+ import { readRunsMoveRecord, retiredRootOf, retiredRootRefusal } from './retired-roots.mjs';
10
16
 
11
- export function runsRoot() {
12
- const configured = process.env.CODEX_RUNS_ROOT;
17
+ export function runsRootResolution({ env = process.env, homedir = os.homedir() } = {}) {
18
+ const brandHome = resolveBrandHome({ env, homedir });
19
+ const homeRoot = path.join(brandHome.root, RUN_RECORDS_DIR);
20
+ const legacyRoot = path.join(homedir, '.claude', 'codex-runs');
21
+ const record = readRunsMoveRecord(brandHome.stateDir);
22
+ const retired = record?.retired ?? [];
23
+ const configured = env.CODEX_RUNS_ROOT;
13
24
  // Trimmed, not taken literally: a path with a leading or trailing space creates a folder
14
25
  // Windows tooling cannot address, and the value usually arrives from a shell or an .env line.
15
- return configured?.trim() ? configured.trim() : path.join(os.homedir(), '.claude', 'codex-runs');
26
+ if (configured?.trim()) {
27
+ const root = configured.trim();
28
+ return { root, source: 'CODEX_RUNS_ROOT', legacyRoot, homeRoot, retired,
29
+ staleOverride: retiredRootOf(root, retired) };
30
+ }
31
+ if (record) {
32
+ return { root: homeRoot, source: 'moved', legacyRoot, homeRoot, retired, staleOverride: null };
33
+ }
34
+ const legacyExists = fs.existsSync(legacyRoot) && fs.statSync(legacyRoot).isDirectory();
35
+ return { root: legacyExists ? legacyRoot : homeRoot, source: legacyExists ? 'legacy' : 'default',
36
+ legacyRoot, homeRoot, retired, staleOverride: null };
37
+ }
38
+
39
+ export function runsRoot() {
40
+ const resolution = runsRootResolution();
41
+ if (resolution.staleOverride) throw new Error(staleOverrideRefusal(resolution));
42
+ return resolution.root;
43
+ }
44
+
45
+ /** Plan_77 D6: diagnose the old address before callers read it; never silently remap it. */
46
+ export function retiredPathRefusal(candidate, resolution = runsRootResolution()) {
47
+ const retired = retiredRootOf(candidate, resolution.retired);
48
+ return retired ? retiredRootRefusal({ candidate, retired, destination: resolution.homeRoot }) : null;
49
+ }
50
+
51
+ export function staleOverrideRefusal(resolution = runsRootResolution()) {
52
+ if (!resolution.staleOverride) return null;
53
+ return retiredRootRefusal({ candidate: resolution.root, retired: resolution.staleOverride,
54
+ destination: resolution.homeRoot }) + '\n' +
55
+ 'CODEX_RUNS_ROOT points under a retired runs root; remove it or set it to the new location.';
16
56
  }