@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.
- package/CHANGELOG.md +115 -0
- package/README.md +10 -8
- package/cli/command-registry.mjs +59 -18
- package/cli/doctor-installation.mjs +132 -111
- package/cli/doctor-load-witness.mjs +75 -0
- package/cli/doctor-runs.mjs +41 -6
- package/cli/doctor-sandbox.mjs +64 -0
- package/cli/doctor.mjs +26 -8
- package/cli/guidance.mjs +42 -0
- package/cli/host-inspection.mjs +1 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install-record.mjs +4 -2
- package/cli/manifest.mjs +6 -3
- package/cli/read.mjs +1 -2
- package/cli/removal-outcomes.mjs +5 -2
- package/cli/removal-plan.mjs +4 -1
- package/cli/remove-layout.mjs +4 -4
- package/cli/run-lookup.mjs +14 -2
- package/cli/runs-inventory.mjs +10 -2
- package/cli/runs-move-copy.mjs +123 -0
- package/cli/runs-move-history.mjs +78 -0
- package/cli/runs-move-remove.mjs +92 -0
- package/cli/runs-move.mjs +96 -0
- package/cli/stop.mjs +1 -2
- package/cli/uninstall-purge.mjs +1 -0
- package/cli/uninstall.mjs +2 -0
- package/cli/update.mjs +12 -7
- package/package.json +4 -2
- package/scripts/home-write-inventory.mjs +17 -2
- package/scripts/isolated-roots.mjs +2 -1
- package/src/{agents → claude/agents}/codex-advisor.md +8 -7
- package/src/{agents → claude/agents}/codex-build.md +7 -6
- package/src/{agents → claude/agents}/codex-review.md +6 -5
- package/src/{agents → claude/agents}/codex-scout.md +7 -6
- package/src/{commands → claude/commands}/usage.md +9 -5
- package/src/claude/rules/core.md +29 -0
- package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
- package/src/home/guidance/advisor.md +42 -0
- package/src/home/guidance/budget.md +37 -0
- package/src/home/guidance/concurrency.md +17 -0
- package/src/home/guidance/order.md +68 -0
- package/src/home/guidance/records.md +32 -0
- package/src/home/hooks/live-runs.mjs +2 -1
- package/src/home/hooks/load-witness.mjs +23 -0
- package/src/home/lib/claude-layout.mjs +1 -0
- package/src/home/lib/codex-home.mjs +8 -0
- package/src/home/lib/home-registry.mjs +21 -0
- package/src/home/lib/hook-definitions.mjs +2 -0
- package/src/home/lib/host-version.mjs +33 -14
- package/src/home/lib/load-witness.mjs +113 -0
- package/src/home/lib/meta/paths.mjs +6 -1
- package/src/home/lib/meta/reply.mjs +1 -1
- package/src/home/lib/runner/codex-availability.mjs +11 -2
- package/src/home/lib/runner/exit-codes.mjs +1 -1
- package/src/home/lib/runner/launcher.mjs +10 -4
- package/src/home/lib/runner/preflight.mjs +12 -4
- package/src/home/lib/runner/prompts.mjs +2 -2
- package/src/home/lib/runner/retired-roots.mjs +81 -0
- package/src/home/lib/runner/run-autocommit.mjs +109 -0
- package/src/home/lib/runner/runs-root.mjs +47 -7
- package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
- package/src/home/lib/runner/sandbox-probe.mjs +29 -1
- package/src/home/lib/runner/task-input.mjs +30 -0
- package/src/home/lib/runner/worker.mjs +5 -0
- /package/src/{commands → claude/commands}/env.md +0 -0
- /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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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;
|
|
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 === '
|
|
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 (
|
|
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
|
-
*
|
|
2
|
+
* One resolver says where run artifacts live: CODEX_RUNS_ROOT, moved, legacy, or default.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
12
|
-
const
|
|
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
|
-
|
|
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
|
}
|