@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +10 -8
  3. package/cli/command-registry.mjs +59 -18
  4. package/cli/doctor-installation.mjs +132 -111
  5. package/cli/doctor-load-witness.mjs +75 -0
  6. package/cli/doctor-runs.mjs +41 -6
  7. package/cli/doctor-sandbox.mjs +64 -0
  8. package/cli/doctor.mjs +26 -8
  9. package/cli/guidance.mjs +42 -0
  10. package/cli/host-inspection.mjs +1 -0
  11. package/cli/hosts.mjs +4 -3
  12. package/cli/install-record.mjs +4 -2
  13. package/cli/manifest.mjs +6 -3
  14. package/cli/read.mjs +1 -2
  15. package/cli/removal-outcomes.mjs +5 -2
  16. package/cli/removal-plan.mjs +4 -1
  17. package/cli/remove-layout.mjs +4 -4
  18. package/cli/run-lookup.mjs +14 -2
  19. package/cli/runs-inventory.mjs +10 -2
  20. package/cli/runs-move-copy.mjs +123 -0
  21. package/cli/runs-move-history.mjs +78 -0
  22. package/cli/runs-move-remove.mjs +92 -0
  23. package/cli/runs-move.mjs +96 -0
  24. package/cli/stop.mjs +1 -2
  25. package/cli/uninstall-purge.mjs +1 -0
  26. package/cli/uninstall.mjs +2 -0
  27. package/cli/update.mjs +12 -7
  28. package/package.json +4 -2
  29. package/scripts/home-write-inventory.mjs +17 -2
  30. package/scripts/isolated-roots.mjs +2 -1
  31. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  32. package/src/{agents → claude/agents}/codex-build.md +7 -6
  33. package/src/{agents → claude/agents}/codex-review.md +6 -5
  34. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  35. package/src/{commands → claude/commands}/usage.md +9 -5
  36. package/src/claude/rules/core.md +29 -0
  37. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  38. package/src/home/guidance/advisor.md +42 -0
  39. package/src/home/guidance/budget.md +37 -0
  40. package/src/home/guidance/concurrency.md +17 -0
  41. package/src/home/guidance/order.md +66 -0
  42. package/src/home/guidance/records.md +32 -0
  43. package/src/home/hooks/live-runs.mjs +2 -1
  44. package/src/home/hooks/load-witness.mjs +23 -0
  45. package/src/home/lib/claude-layout.mjs +1 -0
  46. package/src/home/lib/codex-home.mjs +8 -0
  47. package/src/home/lib/home-registry.mjs +21 -0
  48. package/src/home/lib/hook-definitions.mjs +2 -0
  49. package/src/home/lib/host-version.mjs +33 -14
  50. package/src/home/lib/load-witness.mjs +113 -0
  51. package/src/home/lib/meta/reply.mjs +1 -1
  52. package/src/home/lib/runner/codex-availability.mjs +11 -2
  53. package/src/home/lib/runner/exit-codes.mjs +1 -1
  54. package/src/home/lib/runner/launcher.mjs +10 -4
  55. package/src/home/lib/runner/preflight.mjs +12 -4
  56. package/src/home/lib/runner/retired-roots.mjs +81 -0
  57. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  58. package/src/home/lib/runner/runs-root.mjs +47 -7
  59. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  60. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  61. package/src/home/lib/runner/task-input.mjs +30 -0
  62. package/src/home/lib/runner/worker.mjs +5 -0
  63. /package/src/{commands → claude/commands}/env.md +0 -0
  64. /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
+ }
@@ -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 { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
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(runsRoot(), repoRoot(process.cwd()), { create: false });
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(runsRoot(), { requireConfirmedIdentity: true }).length;
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 { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
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
- checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
200
- const projectRuns = projectRunsCheck();
201
- checks.push(liveRunsCheck());
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,
@@ -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
+ }
@@ -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 || process.env.CODEX_HOME || path.join(homedir, '.codex');
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
@@ -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
- if (entry.path === 'codex-runs' || entry.path.split(/[\\/]/)[0] === 'codex-runs') {
82
- throw new Error('installation record files must not name run artifacts under codex-runs');
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 = runsRoot() } = {}) {
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
 
@@ -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 { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
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 = () => `Run artifacts in ${runsRoot()} are outside uninstall and stay.`;
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 = [];
@@ -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
- action: 'keep', reason: entry.kind === 'directory' ? 'unknown directory' : 'unknown',
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) {
@@ -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. Plan_25 gave the host four package-owned
37
- * directories at once — the current agents and commands subdirectories and the two the previous
38
- * layout used — and a walk that started inside one of them but stopped at host.root would delete
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);
@@ -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 { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
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 = runsRoot(),
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);
@@ -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 directoryEntries(root)
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 directoryEntries(root).map((entry) => buildProject(path.join(root, entry.name)).summary);
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. */