@lyupro/codex-bridge 0.6.12 → 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 +105 -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 +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/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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan_64 D11: the 2026-10-10 live run proved memory_type and entrypoint unreliable;
|
|
3
|
+
* A6 showed a lost hook record cannot prove failure. Judge one host's rules-core load evidence.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { buildInstallPlan, contentFingerprint } from './manifest.mjs';
|
|
7
|
+
import { plannedContent } from './copy.mjs';
|
|
8
|
+
import { check } from './doctor-format.mjs';
|
|
9
|
+
import { LOAD_WITNESS_FILE, readLoadWitness } from '../src/home/lib/load-witness.mjs';
|
|
10
|
+
import { transcriptHostVersion } from '../src/home/lib/host-version.mjs';
|
|
11
|
+
|
|
12
|
+
function normalizedPath(target) {
|
|
13
|
+
const resolved = path.resolve(target).replaceAll('\\', '/');
|
|
14
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadWitnessChecks({ host, record, stateDir, activeHosts, readVersion = transcriptHostVersion }) {
|
|
18
|
+
if (!record) return [check('claudeRulesLoad', 'warn', 'not checked before installation')];
|
|
19
|
+
const witness = readLoadWitness({ stateDir });
|
|
20
|
+
if (witness.corrupt) {
|
|
21
|
+
return [check('claudeRulesLoad', 'warn', `load witness is unreadable: ${path.join(stateDir, LOAD_WITNESS_FILE)}`)];
|
|
22
|
+
}
|
|
23
|
+
const plan = await buildInstallPlan(host);
|
|
24
|
+
const expected = new Map();
|
|
25
|
+
for (const item of plan.filter((item) => path.dirname(item.target) === host.rulesDir)) {
|
|
26
|
+
expected.set(normalizedPath(item.target), contentFingerprint(await plannedContent(item, host.brandRoot)));
|
|
27
|
+
}
|
|
28
|
+
const byVersion = new Map();
|
|
29
|
+
let pending = 0;
|
|
30
|
+
for (const [sessionId, session] of Object.entries(witness.sessions)) {
|
|
31
|
+
if (!Array.isArray(session?.loads)) continue;
|
|
32
|
+
const loads = session.loads.filter((load) => typeof load?.path === 'string'
|
|
33
|
+
&& expected.has(normalizedPath(load.path)));
|
|
34
|
+
if (!loads.length) continue;
|
|
35
|
+
// D11: use the witness session, never a neighboring transcript entry or the current PATH host.
|
|
36
|
+
const version = await readVersion(session.transcriptPath, { sessionId });
|
|
37
|
+
if (version === null) {
|
|
38
|
+
if (loads.some((load) => load.sha256 === expected.get(normalizedPath(load.path)))) pending += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const entries = byVersion.get(version) ?? [];
|
|
42
|
+
for (const load of loads) {
|
|
43
|
+
entries.push({ sessionId, at: load.at, matches: load.sha256 === expected.get(normalizedPath(load.path)),
|
|
44
|
+
hashSkippedReason: load.hashSkippedReason });
|
|
45
|
+
}
|
|
46
|
+
byVersion.set(version, entries);
|
|
47
|
+
}
|
|
48
|
+
const pendingNote = pending ? ` (${pending} session(s) awaiting their transcript)` : '';
|
|
49
|
+
const versions = activeHosts.length ? [...new Set(activeHosts)]
|
|
50
|
+
: [...byVersion].filter(([, entries]) => entries.some((entry) => entry.matches
|
|
51
|
+
|| entry.hashSkippedReason === 'core-too-large')).map(([version]) => version);
|
|
52
|
+
if (!versions.length) {
|
|
53
|
+
return [check('claudeRulesLoad', 'warn',
|
|
54
|
+
`no session has witnessed the rules core loading yet; open a new Claude Code session${pendingNote}`)];
|
|
55
|
+
}
|
|
56
|
+
return versions.map((version) => {
|
|
57
|
+
const entries = byVersion.get(version) ?? [];
|
|
58
|
+
const verified = entries.filter((entry) => entry.matches);
|
|
59
|
+
const latest = [...entries].sort((a, b) => String(b.at).localeCompare(String(a.at)))[0];
|
|
60
|
+
// D15 / review B8: an oversized newest load cannot be proof, even after an older matching load.
|
|
61
|
+
if (latest?.hashSkippedReason === 'core-too-large') {
|
|
62
|
+
return check(`claudeRulesLoad:${version}`, 'warn',
|
|
63
|
+
`rules core hash skipped: core-too-large (host ${version}, latest ${latest.at})${pendingNote}`);
|
|
64
|
+
}
|
|
65
|
+
// D11: a newer load of another package hash invalidates the older positive witness.
|
|
66
|
+
if (latest?.matches && verified.length) {
|
|
67
|
+
const count = new Set(verified.map((entry) => entry.sessionId)).size;
|
|
68
|
+
return check(`claudeRulesLoad:${version}`, 'ok',
|
|
69
|
+
`rules core load witnessed (${count} session(s), latest ${latest.at})${pendingNote}`);
|
|
70
|
+
}
|
|
71
|
+
const stale = latest && !latest.matches ? '; witnessed core differs from this package (stale)' : '';
|
|
72
|
+
return check(`claudeRulesLoad:${version}`, 'warn',
|
|
73
|
+
`no session on host ${version} has witnessed the rules core loading yet${stale}; open a new Claude Code session, then rerun codex-bridge doctor${pendingNote}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
package/cli/doctor-runs.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/** Reports the state of run storage on this machine: location, live count, retention. */
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
5
|
-
import {
|
|
6
|
-
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
6
|
+
import { runsRootResolution, staleOverrideRefusal } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
|
+
import { normalizeRepoPath, resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
8
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
7
9
|
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
8
10
|
import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
|
|
9
11
|
import { check } from './doctor-format.mjs';
|
|
@@ -27,14 +29,47 @@ function repoRoot(cwd) {
|
|
|
27
29
|
return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function runsGitTop(dir) {
|
|
33
|
+
if (!fs.existsSync(dir)) return null;
|
|
34
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', windowsHide: true });
|
|
35
|
+
return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runsRootCheck(options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const resolution = options.resolution ?? runsRootResolution();
|
|
41
|
+
const gitTop = options.gitTop ?? runsGitTop;
|
|
42
|
+
const root = path.resolve(resolution.root);
|
|
43
|
+
const value = root + ' (' + resolution.source + ')';
|
|
44
|
+
if (resolution.staleOverride) {
|
|
45
|
+
return check('runsRoot', 'fail', value + ' — ' + staleOverrideRefusal(resolution));
|
|
46
|
+
}
|
|
47
|
+
if (resolution.source === 'legacy') {
|
|
48
|
+
return check('runsRoot', 'warn', value + ' — move pending: run codex-bridge runs move when no other project is running.');
|
|
49
|
+
}
|
|
50
|
+
if (resolution.source === 'moved' && fs.existsSync(resolution.legacyRoot)
|
|
51
|
+
&& fs.statSync(resolution.legacyRoot).isDirectory()) {
|
|
52
|
+
return check('runsRoot', 'warn', value + ' — the old folder ' + resolution.legacyRoot + ' still exists; codex-bridge runs move removes it.');
|
|
53
|
+
}
|
|
54
|
+
const top = gitTop(root);
|
|
55
|
+
if (top && normalizeRepoPath(top) !== normalizeRepoPath(root)
|
|
56
|
+
&& normalizeRepoPath(top) !== normalizeRepoPath(resolveBrandHome().root)) {
|
|
57
|
+
return check('runsRoot', 'warn', value + ' — run records land in the git repository ' + top + ', which is not their own; they accumulate there uncommitted.');
|
|
58
|
+
}
|
|
59
|
+
return check('runsRoot', 'ok', value);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return check('runsRoot', 'fail', err.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
/**
|
|
31
66
|
* A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
|
|
32
67
|
* Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
|
|
33
68
|
*/
|
|
34
|
-
export function projectRunsCheck() {
|
|
69
|
+
export function projectRunsCheck(resolution) {
|
|
35
70
|
let resolved;
|
|
36
71
|
try {
|
|
37
|
-
resolved = resolveProjectRunsDir(
|
|
72
|
+
resolved = resolveProjectRunsDir(resolution.root, repoRoot(process.cwd()), { create: false });
|
|
38
73
|
} catch (err) {
|
|
39
74
|
return check('projectRuns', 'fail', err.message);
|
|
40
75
|
}
|
|
@@ -42,10 +77,10 @@ export function projectRunsCheck() {
|
|
|
42
77
|
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
43
78
|
}
|
|
44
79
|
|
|
45
|
-
export function liveRunsCheck() {
|
|
80
|
+
export function liveRunsCheck(resolution) {
|
|
46
81
|
let count;
|
|
47
82
|
try {
|
|
48
|
-
count = allLiveRuns(
|
|
83
|
+
count = allLiveRuns(resolution.root, { requireConfirmedIdentity: true }).length;
|
|
49
84
|
} catch (err) {
|
|
50
85
|
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
51
86
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Detects the dead sandbox before an order, after the 2026-09-16, 2026-09-25 and 2026-10-10 refusals. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { check } from './doctor-format.mjs';
|
|
5
|
+
import { repositoryRoot } from './hosts.mjs';
|
|
6
|
+
import { sandboxModeFor } from '../src/home/lib/runner/codex-args.mjs';
|
|
7
|
+
import { probeSandbox } from '../src/home/lib/runner/sandbox-probe.mjs';
|
|
8
|
+
import { formatSandboxDiagnosis } from '../src/home/lib/runner/sandbox-diagnosis.mjs';
|
|
9
|
+
|
|
10
|
+
// Probe reasons are full sentences; joining one to "; probed folder" printed "process.; probed" live.
|
|
11
|
+
const clause = (reason) => String(reason).replace(/\.$/, '');
|
|
12
|
+
|
|
13
|
+
// Plan_78 B4a: a timed-out attempt ends its form: at most 15 s plus ~15 s stopping
|
|
14
|
+
// (taskkill 10 s, then stop grace). Only one form can time out with the guard below;
|
|
15
|
+
// including the version check, the worst case stays well under the host shell's 120 s default.
|
|
16
|
+
export async function sandboxChecks({
|
|
17
|
+
codexAvailable, cwd = process.cwd(), probe = probeSandbox, platform = process.platform,
|
|
18
|
+
timeoutMs = 15_000,
|
|
19
|
+
} = {}) {
|
|
20
|
+
const root = repositoryRoot(cwd);
|
|
21
|
+
const folder = `${root} (${fs.existsSync(path.join(root, '.git')) ? 'git repository' : 'current folder'})`;
|
|
22
|
+
const rows = [];
|
|
23
|
+
let readOnlyInconclusive = false;
|
|
24
|
+
// Plan_57: concurrent helper setups race on one access-rights state file. Keep both forms sequential.
|
|
25
|
+
for (const agent of ['codex-scout', 'codex-build']) {
|
|
26
|
+
const key = `sandbox:${sandboxModeFor(agent)}`;
|
|
27
|
+
if (codexAvailable === false) {
|
|
28
|
+
rows.push(check(key, 'warn', `not probed: Codex CLI is unavailable; folder: ${folder}`));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Plan_57: if taskkill fails, only cmd.exe dies; Codex may still update shared sandbox state.
|
|
32
|
+
if (agent === 'codex-build' && readOnlyInconclusive) {
|
|
33
|
+
rows.push(check(key, 'warn', `not probed: the read-only probe was inconclusive; rerun codex-bridge doctor; probed folder: ${folder}`));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const result = await probe({ agent, repo: root, platform, timeoutMs });
|
|
37
|
+
if (agent === 'codex-scout') readOnlyInconclusive = result.outcome === 'inconclusive';
|
|
38
|
+
switch (result.outcome) {
|
|
39
|
+
case 'alive':
|
|
40
|
+
rows.push(check(key, 'ok', `${clause(result.reason)}; probed folder: ${folder}`));
|
|
41
|
+
break;
|
|
42
|
+
case 'dead': {
|
|
43
|
+
const diagnosis = result.diagnosis == null
|
|
44
|
+
? ['No sandbox log diagnosis is available.'] : formatSandboxDiagnosis(result.diagnosis);
|
|
45
|
+
rows.push(check(key, 'fail', [
|
|
46
|
+
result.reason,
|
|
47
|
+
...diagnosis.map((line) => ` ${line}`),
|
|
48
|
+
' Repair the host sandbox and rerun codex-bridge doctor.',
|
|
49
|
+
` Probed folder: ${folder}`,
|
|
50
|
+
].join('\n')));
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'inconclusive':
|
|
54
|
+
rows.push(check(key, 'warn', `readiness not established: ${clause(result.reason)}; probed folder: ${folder}`));
|
|
55
|
+
break;
|
|
56
|
+
case 'skipped':
|
|
57
|
+
rows.push(check(key, 'warn', `not probed on ${platform}: the package has never observed this platform's sandbox; folder: ${folder}`));
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
throw new Error(`unknown sandbox probe outcome: ${result.outcome}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -5,12 +5,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { packageSource } from './package-source.mjs';
|
|
6
6
|
import { readInstallRecord, packageInfo } from './manifest.mjs';
|
|
7
7
|
import { readInstallRecordFile, recordTarget } from './install-record.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { runsRootResolution } from '../src/home/lib/runner/runs-root.mjs';
|
|
9
9
|
import { check, renderDoctor } from './doctor-format.mjs';
|
|
10
10
|
import { homeOwnersCheck, hostSideCheck, ownerEntry, ownersInSyncCheck } from './doctor-host-side.mjs';
|
|
11
11
|
import { inspectHost } from './host-inspection.mjs';
|
|
12
12
|
import {
|
|
13
13
|
agentsCheck,
|
|
14
|
+
claudeRulesCheck,
|
|
14
15
|
conventionsCheck,
|
|
15
16
|
exists,
|
|
16
17
|
isFile,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
rulesCheck,
|
|
19
20
|
} from './doctor-installation.mjs';
|
|
20
21
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
22
|
+
import { loadWitnessChecks } from './doctor-load-witness.mjs';
|
|
21
23
|
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
22
24
|
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
23
25
|
import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
|
|
@@ -30,7 +32,7 @@ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
|
30
32
|
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
31
33
|
import { activeHostVersions } from './active-hosts.mjs';
|
|
32
34
|
import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
|
|
33
|
-
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
35
|
+
import { liveRunsCheck, projectRunsCheck, retentionCheck, runsRootCheck } from './doctor-runs.mjs';
|
|
34
36
|
|
|
35
37
|
export { renderDoctor };
|
|
36
38
|
|
|
@@ -48,7 +50,10 @@ function sourceCheck() {
|
|
|
48
50
|
export function probeCodex() {
|
|
49
51
|
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
50
52
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
51
|
-
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
53
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, timeout: 15_000 });
|
|
54
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
55
|
+
return { available: false, value: 'Codex CLI version check timed out after 15000 ms.' };
|
|
56
|
+
}
|
|
52
57
|
if (result.error || result.status !== 0) {
|
|
53
58
|
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
54
59
|
}
|
|
@@ -81,6 +86,7 @@ function dispatcherContractChecks({ record, version, stateDir }) {
|
|
|
81
86
|
export async function diagnose({
|
|
82
87
|
host,
|
|
83
88
|
codexProbe = probeCodex,
|
|
89
|
+
sandbox,
|
|
84
90
|
bridgeProbe = probeCodexBridge,
|
|
85
91
|
launcherProbe,
|
|
86
92
|
currentPackage,
|
|
@@ -145,6 +151,8 @@ export async function diagnose({
|
|
|
145
151
|
checks.push(agents);
|
|
146
152
|
const rules = await rulesCheck(host, record);
|
|
147
153
|
checks.push(rules);
|
|
154
|
+
const claudeRules = await claudeRulesCheck(host, record);
|
|
155
|
+
checks.push(claudeRules);
|
|
148
156
|
checks.push(await permissionsCheck(host));
|
|
149
157
|
const bridge = bridgeProbe();
|
|
150
158
|
checks.push(bridgeCommandCheck(bridge));
|
|
@@ -157,6 +165,7 @@ export async function diagnose({
|
|
|
157
165
|
const modelRecord = dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord;
|
|
158
166
|
const activeHosts = injectedActiveHosts ?? (hostVersion !== undefined ? [hostVersion].filter(Boolean)
|
|
159
167
|
: activeHostVersions({ observations: hostObservations, witnessRecord, modelRecord, now }));
|
|
168
|
+
checks.push(...await loadWitnessChecks({ host, record, stateDir, activeHosts }));
|
|
160
169
|
checks.push(sessionHostCheck(observedVersions, hostObservations));
|
|
161
170
|
const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
|
|
162
171
|
const hostContract = contractStatus({
|
|
@@ -194,18 +203,27 @@ export async function diagnose({
|
|
|
194
203
|
|
|
195
204
|
const codex = codexProbe();
|
|
196
205
|
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
206
|
+
if (typeof sandbox === 'function') checks.push(...await sandbox({ codexAvailable: codex.available }));
|
|
197
207
|
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
198
208
|
checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
209
|
+
// Plan_77 D6: catch a corrupt move record without aborting doctor, and share one resolution.
|
|
210
|
+
let resolution;
|
|
211
|
+
const runsRoot = runsRootCheck({ get resolution() { return (resolution = runsRootResolution()); } });
|
|
212
|
+
checks.push(runsRoot);
|
|
213
|
+
const unavailable = 'unavailable: CODEX_RUNS_ROOT points under a retired runs root';
|
|
214
|
+
const projectRuns = resolution?.staleOverride ? check('projectRuns', 'fail', unavailable)
|
|
215
|
+
: resolution ? projectRunsCheck(resolution) : check('projectRuns', 'fail', runsRoot.value);
|
|
216
|
+
checks.push(resolution?.staleOverride ? check('liveRuns', 'fail', unavailable)
|
|
217
|
+
: resolution ? liveRunsCheck(resolution)
|
|
218
|
+
: check('liveRuns', 'warn', `working-run count unavailable: ${runsRoot.value}`));
|
|
202
219
|
checks.push(projectRuns);
|
|
203
220
|
|
|
204
221
|
return {
|
|
205
222
|
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
223
|
+
|| claudeRules.status === 'fail'
|
|
206
224
|
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
207
|
-
|| projectRuns.status === 'fail'
|
|
208
|
-
|| checks.some((item) => /^(hook|dispatcherContract|dispatcherModel):/.test(item.key) && item.status === 'fail')
|
|
225
|
+
|| runsRoot.status === 'fail' || projectRuns.status === 'fail'
|
|
226
|
+
|| checks.some((item) => /^(hook|dispatcherContract|dispatcherModel|sandbox):/.test(item.key) && item.status === 'fail')
|
|
209
227
|
|| checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
|
|
210
228
|
checks,
|
|
211
229
|
record,
|
package/cli/guidance.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Prints detailed package guidance from the installed brand home. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
5
|
+
|
|
6
|
+
export const GUIDANCE_TOPICS = Object.freeze({
|
|
7
|
+
order: 'Task file, header labels, and scope.',
|
|
8
|
+
advisor: 'Scope and advise phases, and the continue grant.',
|
|
9
|
+
budget: 'Run deadline and slicing.',
|
|
10
|
+
records: 'Finding and reading runs.',
|
|
11
|
+
concurrency: 'Parallel and writing runs.',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const topicList = () => [
|
|
15
|
+
'Topics:',
|
|
16
|
+
...Object.entries(GUIDANCE_TOPICS).map(([topic, summary]) => ` ${topic} — ${summary}`),
|
|
17
|
+
'',
|
|
18
|
+
'Usage: codex-bridge guidance [<topic>]',
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
export function guidance(argv, { env = process.env, homedir } = {}) {
|
|
22
|
+
if (argv.length === 0) return { exitCode: 0, output: topicList() };
|
|
23
|
+
if (argv.length > 1) {
|
|
24
|
+
return { exitCode: 2, output: `codex-bridge guidance accepts at most one topic.\n${topicList()}` };
|
|
25
|
+
}
|
|
26
|
+
const [topic] = argv;
|
|
27
|
+
if (!Object.hasOwn(GUIDANCE_TOPICS, topic)) {
|
|
28
|
+
return { exitCode: 2, output: `Unknown guidance topic "${topic}".\n${topicList()}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The 2026-08-26 seeded-config incident forbids reading installed files from the clone.
|
|
32
|
+
const { root } = resolveBrandHome({ homedir, env });
|
|
33
|
+
const file = path.join(root, 'guidance', `${topic}.md`);
|
|
34
|
+
try {
|
|
35
|
+
return { exitCode: 0, output: fs.readFileSync(file, 'utf8') };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
output: `Cannot read guidance file ${file}: ${error.message}\nrun codex-bridge update`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
package/cli/host-inspection.mjs
CHANGED
|
@@ -144,6 +144,7 @@ export async function inspectHost(host, { owner = null, packageRoot } = {}) {
|
|
|
144
144
|
};
|
|
145
145
|
await walk(context, host.agentsDir);
|
|
146
146
|
await walk(context, host.commandsDir);
|
|
147
|
+
await walk(context, host.rulesDir);
|
|
147
148
|
await inspectLegacyDirectory(context, host.legacyAgentsDir, plan.filter((item) => isWithin(host.agentsDir, item.target)));
|
|
148
149
|
await inspectLegacyDirectory(context, host.legacyCommandsDir, plan.filter((item) => isWithin(host.commandsDir, item.target)));
|
|
149
150
|
await inspectLegacyRecord(context);
|
package/cli/hosts.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
6
|
-
import { claudePaths } from '../src/home/lib/claude-layout.mjs';
|
|
6
|
+
import { claudePaths } from '../src/home/lib/claude-layout.mjs';
|
|
7
|
+
import { resolveCodexHome } from '../src/home/lib/codex-home.mjs';
|
|
7
8
|
|
|
8
|
-
function repositoryRoot(start) {
|
|
9
|
+
export function repositoryRoot(start) {
|
|
9
10
|
let current = path.resolve(start);
|
|
10
11
|
while (true) {
|
|
11
12
|
if (fs.existsSync(path.join(current, '.git'))) return current;
|
|
@@ -23,7 +24,7 @@ export function resolveHost({
|
|
|
23
24
|
codexHome,
|
|
24
25
|
brandRoot,
|
|
25
26
|
} = {}) {
|
|
26
|
-
const resolvedCodexHome = codexHome ||
|
|
27
|
+
const resolvedCodexHome = codexHome || resolveCodexHome({ homedir });
|
|
27
28
|
const codexRulesDir = path.join(resolvedCodexHome, 'rules');
|
|
28
29
|
// Plan_25 moves package-owned runtime files out of foreign Claude settings, whose absolute
|
|
29
30
|
// hook paths caused the package-file-layout incident. Keep the brand root overrideable so tests
|
package/cli/install-record.mjs
CHANGED
|
@@ -78,8 +78,10 @@ function normalizedFiles(record) {
|
|
|
78
78
|
validPath(entry.path, 'installation record file path');
|
|
79
79
|
// Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
|
|
80
80
|
// update needs its own guard against deleting a run folder someone listed as an installed file.
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Plan_77 D1: the records move to runs/ of the brand home, so that folder is refused too.
|
|
82
|
+
const top = entry.path.split(/[\\/]/)[0];
|
|
83
|
+
if (top === 'codex-runs' || (entry.root === 'brand' && top === 'runs')) {
|
|
84
|
+
throw new Error(`installation record files must not name run artifacts under ${top}`);
|
|
83
85
|
}
|
|
84
86
|
return { root: entry.root, path: entry.path };
|
|
85
87
|
});
|
package/cli/manifest.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
|
|
46
46
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
47
47
|
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
48
|
-
const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
48
|
+
const RULES_SOURCE = `src/codex/rules/${RULES_NAME}`;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
@@ -57,8 +57,9 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
|
57
57
|
export const SEEDED_SOURCES = Object.freeze(['src/home/config.json', 'src/home/conventions.md']);
|
|
58
58
|
|
|
59
59
|
export const INSTALL_TABLE = Object.freeze([
|
|
60
|
-
{ source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
|
|
61
|
-
{ source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
|
|
60
|
+
{ source: 'src/claude/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
|
|
61
|
+
{ source: 'src/claude/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
|
|
62
|
+
{ source: 'src/claude/rules/*.md', base: 'src/claude/rules', root: 'claude', target: 'rulesDir', processing: 'copy' },
|
|
62
63
|
// The source is the host image. Keeping one source root and one target root makes any future
|
|
63
64
|
// remapping visible as a test failure instead of another clone-only import success.
|
|
64
65
|
{ source: 'src/home/**', root: 'brand', target: 'brandRoot', processing: 'copy' },
|
|
@@ -139,6 +140,8 @@ function rootFor(host, mapping) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
function targetRelative(packageRoot, source, mapping) {
|
|
143
|
+
// Plan_64 D9: copy rows outside src/home need their own base to avoid escaping the target directory.
|
|
144
|
+
if (mapping.base) return path.relative(path.join(packageRoot, mapping.base), source);
|
|
142
145
|
if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
|
|
143
146
|
return path.basename(source);
|
|
144
147
|
}
|
package/cli/read.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import path from 'node:path';
|
|
|
3
3
|
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
4
4
|
import { readEvents } from '../src/home/lib/meta/events.mjs';
|
|
5
5
|
import { safeSlice } from '../src/home/lib/meta/paths.mjs';
|
|
6
|
-
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
6
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
8
7
|
|
|
9
8
|
const result = (exitCode, output) => ({ exitCode, output });
|
|
@@ -85,7 +84,7 @@ function renderEvent(event, transportError) {
|
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
export function read({ run, cwd = process.cwd(), runsRootPath
|
|
87
|
+
export function read({ run, cwd = process.cwd(), runsRootPath } = {}) {
|
|
89
88
|
const lookup = resolveRunFolder({ command: 'read', run, cwd, runsRootPath });
|
|
90
89
|
if (lookup.error) return result(1, lookup.error);
|
|
91
90
|
|
package/cli/removal-outcomes.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Plan_65 D12 item 7 requires actual counts and named keeps: planned removal must not claim
|
|
5
5
|
* that edited image files disappeared, or confuse removing an owner with deleting its record.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { runsRootResolution } from '../src/home/lib/runner/runs-root.mjs';
|
|
8
8
|
|
|
9
9
|
const unsafeReason = (reason) => reason.startsWith('link') || reason.startsWith('unreadable');
|
|
10
10
|
// An image file kept by content evidence is named; one kept by policy (another owner, an incomplete
|
|
@@ -12,7 +12,10 @@ const unsafeReason = (reason) => reason.startsWith('link') || reason.startsWith(
|
|
|
12
12
|
const EVIDENCE_KEEP = new Set(['changed', 'link']);
|
|
13
13
|
const namedKeep = (entry) => unsafeReason(entry.reason) || entry.id === null
|
|
14
14
|
|| (entry.id === 'install-image' && EVIDENCE_KEEP.has(entry.reason));
|
|
15
|
-
const artifactsLine = () =>
|
|
15
|
+
const artifactsLine = () => {
|
|
16
|
+
const resolution = runsRootResolution();
|
|
17
|
+
return `Run artifacts in ${resolution.staleOverride ? resolution.homeRoot : resolution.root} are outside uninstall and stay.`;
|
|
18
|
+
};
|
|
16
19
|
|
|
17
20
|
function fileLines(files, host, dryRun) {
|
|
18
21
|
const lines = [];
|
package/cli/removal-plan.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
HOME_ARTIFACTS,
|
|
10
10
|
HOME_DIRECTORIES,
|
|
11
|
+
RUN_RECORDS_DIR,
|
|
11
12
|
classifyHomePath,
|
|
12
13
|
homeArtifact,
|
|
13
14
|
} from '../src/home/lib/home-registry.mjs';
|
|
@@ -99,7 +100,9 @@ export function planHomeFiles({ command, inspection, imageMembers, imageEvidence
|
|
|
99
100
|
}
|
|
100
101
|
for (const entry of inspection.unknown) {
|
|
101
102
|
add(entry.relative, { id: null, role: null, removal: null }, {
|
|
102
|
-
|
|
103
|
+
// The inspection never walks an undeclared folder; the run records folder is one, by design.
|
|
104
|
+
action: 'keep', reason: entry.relative === RUN_RECORDS_DIR && entry.kind === 'directory' ? 'run records'
|
|
105
|
+
: entry.kind === 'directory' ? 'unknown directory' : 'unknown',
|
|
103
106
|
});
|
|
104
107
|
}
|
|
105
108
|
for (const file of inspection.files) {
|
package/cli/remove-layout.mjs
CHANGED
|
@@ -33,15 +33,15 @@ export async function removeEmptyParents(target, boundary) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* The directory an emptied-parent walk must stop at
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the operator's own emptied directories on the way up.
|
|
36
|
+
* The directory an emptied-parent walk must stop at: five package-owned directories: current
|
|
37
|
+
* agents, commands and rules (Plan_64 D5, D9), plus the two legacy directories (Plan_25).
|
|
38
|
+
* Stopping there preserves the operator's own emptied parent directories.
|
|
40
39
|
*/
|
|
41
40
|
export function claudeBoundary(host, target) {
|
|
42
41
|
const directories = [
|
|
43
42
|
host.agentsDir,
|
|
44
43
|
host.commandsDir,
|
|
44
|
+
host.rulesDir,
|
|
45
45
|
host.legacyAgentsDir,
|
|
46
46
|
host.legacyCommandsDir,
|
|
47
47
|
].filter(Boolean);
|
package/cli/run-lookup.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { git } from '../src/home/lib/runner/git-state.mjs';
|
|
5
5
|
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { retiredPathRefusal, runsRootResolution, staleOverrideRefusal } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
7
|
|
|
8
8
|
function projectRoot(cwd) {
|
|
9
9
|
const result = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
@@ -23,7 +23,8 @@ export function resolveRunFolder({
|
|
|
23
23
|
command = 'stop',
|
|
24
24
|
run,
|
|
25
25
|
cwd = process.cwd(),
|
|
26
|
-
runsRootPath
|
|
26
|
+
runsRootPath,
|
|
27
|
+
resolution = runsRootResolution(),
|
|
27
28
|
} = {}) {
|
|
28
29
|
const commandName = String(command || 'stop');
|
|
29
30
|
const value = String(run ?? '').trim();
|
|
@@ -31,6 +32,17 @@ export function resolveRunFolder({
|
|
|
31
32
|
return refusal(commandName, `${commandName} requires a run folder (full path or bare folder name)`);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// Plan_77 D6: run-lookup.mjs:51-55 only said "Run folder not found" after stat on an old path.
|
|
36
|
+
if (path.isAbsolute(value)) {
|
|
37
|
+
const retired = retiredPathRefusal(value, resolution);
|
|
38
|
+
if (retired) return refusal(commandName, retired);
|
|
39
|
+
}
|
|
40
|
+
if (runsRootPath === undefined) {
|
|
41
|
+
const stale = staleOverrideRefusal(resolution);
|
|
42
|
+
if (stale) return refusal(commandName, stale);
|
|
43
|
+
runsRootPath = resolution.root;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
let runDir;
|
|
35
47
|
try {
|
|
36
48
|
if (path.isAbsolute(value)) runDir = path.resolve(value);
|
package/cli/runs-inventory.mjs
CHANGED
|
@@ -108,6 +108,7 @@ function readRun(runDir, livePaths) {
|
|
|
108
108
|
const live = livePaths.has(pathKey(runDir));
|
|
109
109
|
return {
|
|
110
110
|
run: path.basename(runDir),
|
|
111
|
+
path: path.resolve(runDir),
|
|
111
112
|
agent: text(facts?.agent),
|
|
112
113
|
verdict: live ? 'running' : text(meta ? meta.status : runLiveness({ runDir, status }).state),
|
|
113
114
|
tokens: meta ? number(meta.tokens) : null,
|
|
@@ -120,6 +121,8 @@ function readRun(runDir, livePaths) {
|
|
|
120
121
|
function publicRun(run) {
|
|
121
122
|
return {
|
|
122
123
|
run: run.run,
|
|
124
|
+
// D10: the core names no run root, so each JSON row must carry its address.
|
|
125
|
+
path: run.path,
|
|
123
126
|
agent: run.agent,
|
|
124
127
|
verdict: run.verdict,
|
|
125
128
|
tokens: run.tokens,
|
|
@@ -155,16 +158,21 @@ function buildProject(projectDir) {
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
function projectDirectory(root, name) {
|
|
158
|
-
return
|
|
161
|
+
return projectEntries(root)
|
|
159
162
|
.find((entry) => entry.name === name)
|
|
160
163
|
? path.join(root, name)
|
|
161
164
|
: null;
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
function projectEntries(root) {
|
|
168
|
+
// Plan_77 B5b makes the moved store a git repository; its metadata is not a project.
|
|
169
|
+
return directoryEntries(root).filter((entry) => entry.name !== '.git');
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
/** Lists one summary row per project directory. */
|
|
165
173
|
export function listProjects(runsRootPath = runsRoot()) {
|
|
166
174
|
const root = normalizeRoot(runsRootPath);
|
|
167
|
-
return
|
|
175
|
+
return projectEntries(root).map((entry) => buildProject(path.join(root, entry.name)).summary);
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
/** Lists one detail row per run directory in a named project, or null when there is no such project. */
|