@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.
- package/CHANGELOG.md +139 -0
- package/README.md +12 -9
- package/cli/active-hosts.mjs +44 -0
- package/cli/command-registry.mjs +58 -1
- package/cli/dispatcher-model-check.mjs +14 -12
- package/cli/doctor-host-side.mjs +117 -111
- 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 +43 -18
- package/cli/guidance.mjs +42 -0
- package/cli/handback-witness-check.mjs +23 -19
- package/cli/host-inspection.mjs +1 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install-record.mjs +9 -2
- package/cli/inventory-confirm.mjs +75 -0
- package/cli/inventory-transition.mjs +3 -1
- 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 +66 -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/install-owner-roots.mjs +55 -53
- package/src/home/lib/load-witness.mjs +113 -0
- 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/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
|
@@ -1,24 +1,43 @@
|
|
|
1
|
-
/** Reads the host version from
|
|
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
|
-
|
|
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
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 (
|
|
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.
|
|
@@ -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
|
+
}
|