@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +12 -9
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +58 -1
  5. package/cli/dispatcher-model-check.mjs +14 -12
  6. package/cli/doctor-host-side.mjs +117 -111
  7. package/cli/doctor-installation.mjs +132 -111
  8. package/cli/doctor-load-witness.mjs +75 -0
  9. package/cli/doctor-runs.mjs +41 -6
  10. package/cli/doctor-sandbox.mjs +64 -0
  11. package/cli/doctor.mjs +43 -18
  12. package/cli/guidance.mjs +42 -0
  13. package/cli/handback-witness-check.mjs +23 -19
  14. package/cli/host-inspection.mjs +1 -0
  15. package/cli/hosts.mjs +4 -3
  16. package/cli/install-record.mjs +9 -2
  17. package/cli/inventory-confirm.mjs +75 -0
  18. package/cli/inventory-transition.mjs +3 -1
  19. package/cli/manifest.mjs +6 -3
  20. package/cli/read.mjs +1 -2
  21. package/cli/removal-outcomes.mjs +5 -2
  22. package/cli/removal-plan.mjs +4 -1
  23. package/cli/remove-layout.mjs +4 -4
  24. package/cli/run-lookup.mjs +14 -2
  25. package/cli/runs-inventory.mjs +10 -2
  26. package/cli/runs-move-copy.mjs +123 -0
  27. package/cli/runs-move-history.mjs +78 -0
  28. package/cli/runs-move-remove.mjs +92 -0
  29. package/cli/runs-move.mjs +96 -0
  30. package/cli/stop.mjs +1 -2
  31. package/cli/uninstall-purge.mjs +1 -0
  32. package/cli/uninstall.mjs +2 -0
  33. package/cli/update.mjs +12 -7
  34. package/package.json +4 -2
  35. package/scripts/home-write-inventory.mjs +17 -2
  36. package/scripts/isolated-roots.mjs +2 -1
  37. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  38. package/src/{agents → claude/agents}/codex-build.md +7 -6
  39. package/src/{agents → claude/agents}/codex-review.md +6 -5
  40. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  41. package/src/{commands → claude/commands}/usage.md +9 -5
  42. package/src/claude/rules/core.md +29 -0
  43. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  44. package/src/home/guidance/advisor.md +42 -0
  45. package/src/home/guidance/budget.md +37 -0
  46. package/src/home/guidance/concurrency.md +17 -0
  47. package/src/home/guidance/order.md +66 -0
  48. package/src/home/guidance/records.md +32 -0
  49. package/src/home/hooks/live-runs.mjs +2 -1
  50. package/src/home/hooks/load-witness.mjs +23 -0
  51. package/src/home/lib/claude-layout.mjs +1 -0
  52. package/src/home/lib/codex-home.mjs +8 -0
  53. package/src/home/lib/home-registry.mjs +21 -0
  54. package/src/home/lib/hook-definitions.mjs +2 -0
  55. package/src/home/lib/host-version.mjs +33 -14
  56. package/src/home/lib/install-owner-roots.mjs +55 -53
  57. package/src/home/lib/load-witness.mjs +113 -0
  58. package/src/home/lib/meta/reply.mjs +1 -1
  59. package/src/home/lib/runner/codex-availability.mjs +11 -2
  60. package/src/home/lib/runner/exit-codes.mjs +1 -1
  61. package/src/home/lib/runner/launcher.mjs +10 -4
  62. package/src/home/lib/runner/preflight.mjs +12 -4
  63. package/src/home/lib/runner/retired-roots.mjs +81 -0
  64. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  65. package/src/home/lib/runner/runs-root.mjs +47 -7
  66. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  67. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  68. package/src/home/lib/runner/task-input.mjs +30 -0
  69. package/src/home/lib/runner/worker.mjs +5 -0
  70. /package/src/{commands → claude/commands}/env.md +0 -0
  71. /package/src/{commands → claude/commands}/model.md +0 -0
@@ -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,18 +19,20 @@ 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
- import { handbackWitnessStatus } from './handback-witness-check.mjs';
24
+ import { handbackWitnessStatus } from './handback-witness-check.mjs';
23
25
  import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
24
26
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
25
27
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
26
28
  import { PROBE_COMMAND } from './host-contract.mjs';
27
- import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
29
+ import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
28
30
  import { readDispatcherModel } from '../src/home/lib/dispatcher-model.mjs';
29
31
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
30
32
  import { readHostObservations } from '../src/home/lib/host-observations.mjs';
33
+ import { activeHostVersions } from './active-hosts.mjs';
31
34
  import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
32
- import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
35
+ import { liveRunsCheck, projectRunsCheck, retentionCheck, runsRootCheck } from './doctor-runs.mjs';
33
36
 
34
37
  export { renderDoctor };
35
38
 
@@ -47,7 +50,10 @@ function sourceCheck() {
47
50
  export function probeCodex() {
48
51
  const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
49
52
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
50
- 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
+ }
51
57
  if (result.error || result.status !== 0) {
52
58
  return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
53
59
  }
@@ -80,14 +86,17 @@ function dispatcherContractChecks({ record, version, stateDir }) {
80
86
  export async function diagnose({
81
87
  host,
82
88
  codexProbe = probeCodex,
89
+ sandbox,
83
90
  bridgeProbe = probeCodexBridge,
84
91
  launcherProbe,
85
92
  currentPackage,
86
93
  contractRecord,
87
- handbackWitnessRecord,
94
+ handbackWitnessRecord,
88
95
  dispatcherModelRecord,
89
96
  dispatcherContractRecord,
90
97
  hostVersion,
98
+ activeHosts: injectedActiveHosts,
99
+ now,
91
100
  observations,
92
101
  } = {}) {
93
102
  const checks = [sourceCheck()];
@@ -142,6 +151,8 @@ export async function diagnose({
142
151
  checks.push(agents);
143
152
  const rules = await rulesCheck(host, record);
144
153
  checks.push(rules);
154
+ const claudeRules = await claudeRulesCheck(host, record);
155
+ checks.push(claudeRules);
145
156
  checks.push(await permissionsCheck(host));
146
157
  const bridge = bridgeProbe();
147
158
  checks.push(bridgeCommandCheck(bridge));
@@ -150,6 +161,11 @@ export async function diagnose({
150
161
  const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
151
162
  const observedVersions = sessionHostVersions(hostObservations);
152
163
  const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
164
+ const witnessRecord = handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord;
165
+ const modelRecord = dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord;
166
+ const activeHosts = injectedActiveHosts ?? (hostVersion !== undefined ? [hostVersion].filter(Boolean)
167
+ : activeHostVersions({ observations: hostObservations, witnessRecord, modelRecord, now }));
168
+ checks.push(...await loadWitnessChecks({ host, record, stateDir, activeHosts }));
153
169
  checks.push(sessionHostCheck(observedVersions, hostObservations));
154
170
  const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
155
171
  const hostContract = contractStatus({
@@ -161,15 +177,15 @@ export async function diagnose({
161
177
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
162
178
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
163
179
  const witness = handbackWitnessStatus({
164
- record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
165
- hostVersion: detectedHostVersion,
180
+ record: witnessRecord,
181
+ activeHosts,
166
182
  stateDir,
167
183
  });
168
- checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
169
- checks.push(...dispatcherModelChecks({
170
- record: dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord,
171
- hostVersion: detectedHostVersion,
172
- stateDir,
184
+ checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
185
+ checks.push(...dispatcherModelChecks({
186
+ record: modelRecord,
187
+ activeHosts,
188
+ stateDir,
173
189
  }));
174
190
  const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
175
191
  checks.push(...dispatcherContractChecks({
@@ -187,18 +203,27 @@ export async function diagnose({
187
203
 
188
204
  const codex = codexProbe();
189
205
  checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
206
+ if (typeof sandbox === 'function') checks.push(...await sandbox({ codexAvailable: codex.available }));
190
207
  const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
191
208
  checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
192
- checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
193
- const projectRuns = projectRunsCheck();
194
- 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}`));
195
219
  checks.push(projectRuns);
196
220
 
197
221
  return {
198
222
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
223
+ || claudeRules.status === 'fail'
199
224
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
200
- || projectRuns.status === 'fail'
201
- || 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')
202
227
  || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
203
228
  checks,
204
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
+ }
@@ -1,37 +1,41 @@
1
- /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
1
+ /** Surfaces handback drift; Plan_67 D12 judges every host with recent dispatcher activity. */
2
2
  import path from 'node:path';
3
3
  import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
4
  import { entryState } from '../src/home/lib/observation-ledger.mjs';
5
5
 
6
- export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
6
+ export function handbackWitnessStatus({ record, activeHosts, stateDir }) {
7
7
  if (record?.corrupt) {
8
8
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
9
9
  }
10
10
  const entries = Object.values(record?.ledger?.entries || {});
11
+ let history = '';
12
+ const recovered = entries.filter((entry) => entryState(entry) === 'recovered').length;
13
+ const historical = entries.filter((entry) => entryState(entry) === 'violation'
14
+ && !activeHosts.includes(entry.lastObservation.data.hostVersion)).length;
15
+ const legacy = record?.legacy || [];
16
+ if (recovered || historical || legacy.length) {
17
+ const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
18
+ const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
19
+ history = ` History: ${recovered} recovered entries; ${historical} unresolved entries on hosts without dispatcher activity in the last 24 hours (history); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
20
+ }
11
21
  const violations = entries.filter((entry) => entryState(entry) === 'violation'
12
- && entry.lastObservation.data.hostVersion === hostVersion);
22
+ && activeHosts.includes(entry.lastObservation.data.hostVersion));
13
23
  if (violations.length > 0) {
14
24
  const details = violations.map((entry) => {
15
25
  const { data, detail, at } = entry.lastViolation;
16
- return `${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
26
+ return `host ${data.hostVersion}: ${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
17
27
  });
18
28
  return {
19
29
  state: 'violation',
20
- message: `Dispatcher violation(s) on host ${hostVersion ?? 'unknown host'}: ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
30
+ message: `Dispatcher violation(s): ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.${history}`,
21
31
  };
22
32
  }
23
- const intercepted = record?.intercepted?.[hostVersion || 'unknown'];
24
- let message = intercepted
25
- ? `Newest intercepted handback on host ${hostVersion ?? 'unknown host'} at ${intercepted}.`
26
- : 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.';
27
- const recovered = entries.filter((entry) => entryState(entry) === 'recovered').length;
28
- const historical = entries.filter((entry) => entryState(entry) === 'violation'
29
- && entry.lastObservation.data.hostVersion !== hostVersion).length;
30
- const legacy = record?.legacy || [];
31
- if (recovered || historical || legacy.length) {
32
- const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
33
- const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
34
- message += ` History: ${recovered} recovered entries; ${historical} unresolved entries on other hosts (historical, not current); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
35
- }
36
- return { state: 'ok', message };
33
+ const intercepted = activeHosts.filter((host) => record?.intercepted?.[host])
34
+ .map((host) => `host ${host} at ${record.intercepted[host]}`);
35
+ const message = !activeHosts.length
36
+ ? 'No dispatcher activity in the last 24 hours.'
37
+ : intercepted.length
38
+ ? `Newest intercepted handback on ${intercepted.join('; ')}.`
39
+ : 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.';
40
+ return { state: 'ok', message: message + history };
37
41
  }
@@ -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
  });
@@ -307,6 +309,11 @@ async function publishInstallRecord(host, next) {
307
309
  await writer.rename('install-record', temporary, target);
308
310
  }
309
311
 
312
+ /** Plan_67 D11 / A4: changing inventory must not rebuild owners or hide fingerprint lag. */
313
+ export function publishInstallRecordOnly(host, next) {
314
+ return publishInstallRecord(host, next);
315
+ }
316
+
310
317
  /**
311
318
  * Publishes the record whole through a temporary and a rename: a direct write left a half-written
312
319
  * record for every host sharing the home if the process died mid-write (advice A3, risk r3).
@@ -0,0 +1,75 @@
1
+ /** Lets the operator declare a shared home's installation inventory complete, the one statement the package cannot prove.
2
+ * Plan_67 D11, OW-047: only the operator can account for unrecorded hosts using this home.
3
+ */
4
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
5
+ import { publishInstallRecordOnly, readInstallRecordFile } from './install-record.mjs';
6
+ import { validateFormat2 } from './install-owners.mjs';
7
+ import { registryHintLines } from './inventory-transition.mjs';
8
+ import { withLifecycle } from './lifecycle-transaction.mjs';
9
+ import { readRulesRegistry } from './rules-owners.mjs';
10
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
11
+
12
+ export const INVENTORY_CONFIRM_COMMAND = 'codex-bridge inventory confirm';
13
+
14
+ const question = 'Are these all the hosts using this home?';
15
+ const updateHint = 'Run codex-bridge update to migrate the installation record.';
16
+
17
+ async function confirmInRun(host, dryRun, questionOptions) {
18
+ let record;
19
+ try {
20
+ record = await readInstallRecordFile(host);
21
+ if (!isFormat2(record)) {
22
+ return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
23
+ }
24
+ validateFormat2(record);
25
+ } catch (error) {
26
+ return { exitCode: 1, output: `Invalid installation record: ${error.message} ${updateHint}` };
27
+ }
28
+ const owners = Object.values(record.owners);
29
+ if (owners.length === 0) {
30
+ return { exitCode: 1, output: 'no recorded owners; install into each host first' };
31
+ }
32
+ if (record.inventory === 'complete' && !Object.hasOwn(record, 'legacy')) {
33
+ return { exitCode: 0, output: 'The inventory is already complete.' };
34
+ }
35
+
36
+ const screen = [
37
+ `Home: ${host.brandRoot}`,
38
+ 'Recorded hosts:',
39
+ ...owners.map((owner) => ` ${owner.root}`),
40
+ ...registryHintLines(host, (await readRulesRegistry(host))?.owners ?? []),
41
+ "Confirming lets the last recorded host's uninstall remove the shared image. "
42
+ + 'A host that uses this home but is not listed must be enrolled first: '
43
+ + 'codex-bridge install --host "<path>".',
44
+ ].join('\n');
45
+ if (dryRun) return { exitCode: 0, output: `${screen}\nDry run: nothing changed.` };
46
+ const prompt = `${screen}\n${question}`;
47
+ if (!isInteractive(questionOptions)) {
48
+ return { exitCode: 1, output: `${prompt}\nRun this command in a terminal to answer.` };
49
+ }
50
+ const answer = await askYesNo(prompt, questionOptions);
51
+ if (answer === 'cancel') {
52
+ return { exitCode: 130, output: `${prompt}\nCancelled; nothing was changed.` };
53
+ }
54
+ if (answer !== 'yes') {
55
+ return { exitCode: 1, output: `${prompt}\nNothing changed: the inventory stays incomplete.` };
56
+ }
57
+
58
+ // A4 pre-mortem: the raw fields are evidence of lag, not material to regenerate on confirmation.
59
+ const next = { ...record, inventory: 'complete' };
60
+ delete next.legacy;
61
+ await publishInstallRecordOnly(host, next);
62
+ return { exitCode: 0, output: `${prompt}\nRecorded the inventory as complete.` };
63
+ }
64
+
65
+ export async function inventoryConfirm({ host, dryRun = false, ...questionOptions }) {
66
+ const action = (ticket) => {
67
+ // Plan_67 R2: a first install may create the home after the unlocked missing-home check.
68
+ if (!dryRun && ticket === undefined) {
69
+ return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
70
+ }
71
+ return confirmInRun(host, dryRun, questionOptions);
72
+ };
73
+ // Plan_67 D11: dry-run only reads; a real declaration holds the lifecycle lock before reading.
74
+ return dryRun ? action() : withLifecycle(host, 'inventory', action);
75
+ }
@@ -3,6 +3,7 @@
3
3
  * store nothing about a refusal.
4
4
  */
5
5
  import fs from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { askYesNo, isInteractive } from './terminal-question.mjs';
8
9
  import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
@@ -36,7 +37,8 @@ export function registryHintLines(host, candidates) {
36
37
  const otherCandidates = candidates.filter((root) => root !== owner);
37
38
  return [
38
39
  'Codex rules registry host roots are a hint only; they may be stale or belong to another home:',
39
- ...(otherCandidates.length ? otherCandidates.map((root) => ` ${root}`) : [' none found']),
40
+ ...(otherCandidates.length ? otherCandidates.map((root) =>
41
+ ` ${root}${existsSync(root) ? '' : ' (folder does not exist)'}`) : [' none found']),
40
42
  ];
41
43
  }
42
44
 
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) {