@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,111 +1,117 @@
1
- /**
2
- * Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
3
- * comes from its marks, while WHO uses the home comes only from its owner rows. A single
4
- * "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
5
- * never said which hosts the home record names or whether its inventory is complete.
6
- * Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
7
- */
8
- import fs from 'node:fs/promises';
9
- import path from 'node:path';
10
- import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
- import { check } from './doctor-format.mjs';
12
- import { hasPackageMarks } from './host-inspection.mjs';
13
- import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
- import { imageFingerprint } from './install-owners.mjs';
15
-
16
- // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
17
- export function ownerEntry(rawRecord, host) {
18
- if (rawRecord === null || !isFormat2(rawRecord)) return null;
19
- return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
20
- }
21
-
22
- export function hostSideCheck(host, inspection) {
23
- if (inspection.settingsError) {
24
- return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
25
- }
26
- if (!hasPackageMarks(inspection)) {
27
- return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
28
- }
29
-
30
- const kept = inspection.files.filter((file) => file.disposition === 'keep'
31
- && ['changed', 'unknown'].includes(file.reason));
32
- const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
33
- const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
34
- return kept.length
35
- ? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
36
- : check('host files', 'ok', counts);
37
- }
38
-
39
- async function homeImageExists(host) {
40
- try {
41
- await fs.access(path.join(host.brandRoot, 'lib'));
42
- return true;
43
- } catch (error) {
44
- if (error.code === 'ENOENT') return false;
45
- throw error;
46
- }
47
- }
48
-
49
- export async function homeOwnersCheck(host, rawRecord, inspection) {
50
- if (rawRecord === null) {
51
- return await homeImageExists(host)
52
- ? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
53
- : check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
54
- }
55
- if (!isFormat2(rawRecord)) {
56
- return check(
57
- 'home owners',
58
- 'warn',
59
- 'old record format; the hosts using this home are unknown until the next install or update',
60
- );
61
- }
62
-
63
- const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
64
- const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
65
- const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
66
- const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
67
- const parts = [
68
- `recorded: ${roots.length ? roots.join(', ') : 'none'}`,
69
- inventoryComplete ? 'inventory complete' : 'inventory incomplete: an old record did not name every host',
70
- ...(unrecordedMarks
71
- ? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
72
- : []),
73
- ];
74
- return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
75
- }
76
-
77
- export function ownersInSyncCheck(rawRecord) {
78
- if (rawRecord === null || !isFormat2(rawRecord)) {
79
- return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
80
- }
81
-
82
- const owners = Object.values(rawRecord.owners);
83
- const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
84
- if (!owners.length) {
85
- return check('owners in sync', 'warn',
86
- `no recorded owners${inventoryComplete ? '' : '; inventory incomplete'}`);
87
- }
88
-
89
- const fingerprint = imageFingerprint(rawRecord.image);
90
- const parts = [];
91
- const unstamped = [];
92
- for (const owner of owners) {
93
- if (owner.imageFingerprint != null) {
94
- if (owner.imageFingerprint === fingerprint) continue;
95
- } else if (owner.version === rawRecord.image.version) {
96
- unstamped.push(owner.root);
97
- continue;
98
- }
99
- // The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
100
- const why = owner.version === rawRecord.image.version
101
- ? `verified against an earlier image of ${owner.version}`
102
- : `recorded ${owner.version}, image ${rawRecord.image.version}`;
103
- parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
104
- }
105
- if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
106
- if (!inventoryComplete) parts.push('inventory incomplete');
107
- const warning = parts.length > 0;
108
- if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
109
- return check('owners in sync', warning ? 'warn' : 'ok',
110
- `${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
111
- }
1
+ /**
2
+ * Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
3
+ * comes from its marks, while WHO uses the home comes only from its owner rows. A single
4
+ * "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
5
+ * never said which hosts the home record names or whether its inventory is complete.
6
+ * Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
+ import { check } from './doctor-format.mjs';
12
+ import { hasPackageMarks } from './host-inspection.mjs';
13
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
+ import { imageFingerprint } from './install-owners.mjs';
15
+ import { INVENTORY_CONFIRM_COMMAND } from './inventory-confirm.mjs';
16
+
17
+ const inventoryRepair = `; run ${INVENTORY_CONFIRM_COMMAND} if the recorded hosts are all of them`;
18
+
19
+ const ownerRepair = '; install into each host first: run codex-bridge install --host "<path>"';
20
+
21
+ // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
22
+ export function ownerEntry(rawRecord, host) {
23
+ if (rawRecord === null || !isFormat2(rawRecord)) return null;
24
+ return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
25
+ }
26
+
27
+ export function hostSideCheck(host, inspection) {
28
+ if (inspection.settingsError) {
29
+ return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
30
+ }
31
+ if (!hasPackageMarks(inspection)) {
32
+ return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
33
+ }
34
+
35
+ const kept = inspection.files.filter((file) => file.disposition === 'keep'
36
+ && ['changed', 'unknown'].includes(file.reason));
37
+ const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
38
+ const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
39
+ return kept.length
40
+ ? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
41
+ : check('host files', 'ok', counts);
42
+ }
43
+
44
+ async function homeImageExists(host) {
45
+ try {
46
+ await fs.access(path.join(host.brandRoot, 'lib'));
47
+ return true;
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return false;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ export async function homeOwnersCheck(host, rawRecord, inspection) {
55
+ if (rawRecord === null) {
56
+ return await homeImageExists(host)
57
+ ? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
58
+ : check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
59
+ }
60
+ if (!isFormat2(rawRecord)) {
61
+ return check(
62
+ 'home owners',
63
+ 'warn',
64
+ 'old record format; the hosts using this home are unknown until the next install or update',
65
+ );
66
+ }
67
+
68
+ const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
69
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
70
+ const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
71
+ const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
72
+ const parts = [
73
+ `recorded: ${roots.length ? roots.join(', ') : 'none'}`,
74
+ inventoryComplete ? 'inventory complete'
75
+ : `inventory incomplete: an old record did not name every host${roots.length ? inventoryRepair : ownerRepair}`,
76
+ ...(unrecordedMarks
77
+ ? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
78
+ : []),
79
+ ];
80
+ return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
81
+ }
82
+
83
+ export function ownersInSyncCheck(rawRecord) {
84
+ if (rawRecord === null || !isFormat2(rawRecord)) {
85
+ return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
86
+ }
87
+
88
+ const owners = Object.values(rawRecord.owners);
89
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
90
+ if (!owners.length) {
91
+ return check('owners in sync', 'warn',
92
+ `no recorded owners${inventoryComplete ? '' : `; inventory incomplete${ownerRepair}`}`);
93
+ }
94
+
95
+ const fingerprint = imageFingerprint(rawRecord.image);
96
+ const parts = [];
97
+ const unstamped = [];
98
+ for (const owner of owners) {
99
+ if (owner.imageFingerprint != null) {
100
+ if (owner.imageFingerprint === fingerprint) continue;
101
+ } else if (owner.version === rawRecord.image.version) {
102
+ unstamped.push(owner.root);
103
+ continue;
104
+ }
105
+ // The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
106
+ const why = owner.version === rawRecord.image.version
107
+ ? `verified against an earlier image of ${owner.version}`
108
+ : `recorded ${owner.version}, image ${rawRecord.image.version}`;
109
+ parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
110
+ }
111
+ if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
112
+ if (!inventoryComplete) parts.push(`inventory incomplete${inventoryRepair}`);
113
+ const warning = parts.length > 0;
114
+ if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
115
+ return check('owners in sync', warning ? 'warn' : 'ok',
116
+ `${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
117
+ }
@@ -1,111 +1,132 @@
1
- /** Reports whether what this package installed into the host still matches the package. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import {
5
- buildInstallPlan,
6
- contentFingerprint,
7
- fileFingerprint,
8
- } from './manifest.mjs';
9
- import { plannedContent } from './copy.mjs';
10
- import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
- import { readRulesRegistry } from './rules-owners.mjs';
12
- import { parseFrontmatter } from '../src/home/lib/frontmatter.mjs';
13
- import { check } from './doctor-format.mjs';
14
-
15
- export async function exists(target) {
16
- try {
17
- await fs.access(target);
18
- return true;
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- export async function isFile(target) {
25
- try {
26
- return (await fs.stat(target)).isFile();
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
- export async function agentsCheck(host, record) {
33
- if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
- const plan = await buildInstallPlan(host);
35
- const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
- const mismatches = [];
37
- const unreadable = [];
38
- for (const item of agents) {
39
- const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
- if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
- const expectedName = path.basename(item.source, '.md');
42
- try {
43
- const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
- if (!frontmatter) throw new Error('frontmatter is missing');
45
- if (frontmatter.name !== expectedName) {
46
- throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
- }
48
- } catch (err) {
49
- unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
- }
51
- }
52
- // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
- // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
- // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
- // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
- if (unreadable.length) {
57
- return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
- }
59
- return mismatches.length
60
- ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
- : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
- }
63
-
64
- export async function conventionsCheck(host) {
65
- const file = host.brandConventionsPath;
66
- let content;
67
- try {
68
- content = await fs.readFile(file, 'utf8');
69
- } catch (err) {
70
- if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
- return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
- }
73
- return content.trim()
74
- ? check('conventions', 'ok', `${file} (found)`)
75
- : check('conventions', 'warn', `${file} (found but empty)`);
76
- }
77
-
78
- export async function rulesCheck(host, record) {
79
- if (!record) return check('rules', 'warn', 'cannot check before installation');
80
- if (!record.rules) {
81
- return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
- }
83
- let registry;
84
- try {
85
- registry = await readRulesRegistry(host);
86
- } catch (err) {
87
- // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
- return check('rules', 'fail', err.message);
89
- }
90
- const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
- const fingerprint = await fileFingerprint(record.rules.path);
92
- if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
- return fingerprint === record.rules.fingerprint
94
- ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
- : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
- }
97
-
98
- export async function permissionsCheck(host) {
99
- try {
100
- const status = await inspectPermissions(host.settingsPath);
101
- // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
- // doctor does not turn a healthy installation red merely because hardening was not requested.
103
- // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
- // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
- const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
- return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
- `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
- } catch (err) {
109
- return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
- }
111
- }
1
+ /** Reports whether what this package installed into the host still matches the package. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ buildInstallPlan,
6
+ contentFingerprint,
7
+ fileFingerprint,
8
+ } from './manifest.mjs';
9
+ import { plannedContent } from './copy.mjs';
10
+ import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { parseFrontmatter } from '../src/home/lib/frontmatter.mjs';
13
+ import { check } from './doctor-format.mjs';
14
+
15
+ export async function exists(target) {
16
+ try {
17
+ await fs.access(target);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function isFile(target) {
25
+ try {
26
+ return (await fs.stat(target)).isFile();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export async function agentsCheck(host, record) {
33
+ if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
+ const plan = await buildInstallPlan(host);
35
+ // Selected by where the file lands, not by where it comes from: Plan_64 B1 moved the sources
36
+ // under src/claude/, a source-path pattern matched nothing, and this check reported
37
+ // "0 installed agent definition(s) match" as ok.
38
+ const agents = plan.filter((item) => path.dirname(item.target) === host.agentsDir && item.target.endsWith('.md'));
39
+ if (agents.length === 0) return check('agents', 'fail', 'This package plans no agent definitions; the package tree is broken');
40
+ const mismatches = [];
41
+ const unreadable = [];
42
+ for (const item of agents) {
43
+ const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
44
+ if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
45
+ const expectedName = path.basename(item.source, '.md');
46
+ try {
47
+ const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
48
+ if (!frontmatter) throw new Error('frontmatter is missing');
49
+ if (frontmatter.name !== expectedName) {
50
+ throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
51
+ }
52
+ } catch (err) {
53
+ unreadable.push(`${path.basename(item.target)} (${err.message})`);
54
+ }
55
+ }
56
+ // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
57
+ // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
58
+ // the package command — in an installed definition: doctor kept exit 0 while every delegation
59
+ // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
60
+ if (unreadable.length) {
61
+ return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
62
+ }
63
+ return mismatches.length
64
+ ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
65
+ : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
66
+ }
67
+
68
+ export async function claudeRulesCheck(host, record) {
69
+ if (!record) return check('claudeRules', 'warn', 'Host rules were not checked; run codex-bridge install');
70
+ const plan = await buildInstallPlan(host);
71
+ // Plan_64 B1: source-path selection hid an empty plan as a healthy installation.
72
+ const rules = plan.filter((item) => path.dirname(item.target) === host.rulesDir);
73
+ if (rules.length === 0) return check('claudeRules', 'fail', 'This package plans no host rules; the package tree is broken');
74
+ const mismatches = [];
75
+ for (const item of rules) {
76
+ const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
77
+ if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
78
+ }
79
+ // Plan_64 B3: equal bytes imply no paths frontmatter; tests/claude-rules-core.test.mjs guards the package core.
80
+ return mismatches.length
81
+ ? check('claudeRules', 'fail', `Host rules differ from this package or are missing: ${mismatches.join(', ')}; run codex-bridge update --force`)
82
+ : check('claudeRules', 'ok', `${rules.length} host rules file(s) match this package`);
83
+ }
84
+
85
+ export async function conventionsCheck(host) {
86
+ const file = host.brandConventionsPath;
87
+ let content;
88
+ try {
89
+ content = await fs.readFile(file, 'utf8');
90
+ } catch (err) {
91
+ if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
92
+ return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
93
+ }
94
+ return content.trim()
95
+ ? check('conventions', 'ok', `${file} (found)`)
96
+ : check('conventions', 'warn', `${file} (found but empty)`);
97
+ }
98
+
99
+ export async function rulesCheck(host, record) {
100
+ if (!record) return check('rules', 'warn', 'cannot check before installation');
101
+ if (!record.rules) {
102
+ return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
103
+ }
104
+ let registry;
105
+ try {
106
+ registry = await readRulesRegistry(host);
107
+ } catch (err) {
108
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
109
+ return check('rules', 'fail', err.message);
110
+ }
111
+ const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
112
+ const fingerprint = await fileFingerprint(record.rules.path);
113
+ if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
114
+ return fingerprint === record.rules.fingerprint
115
+ ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
116
+ : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
117
+ }
118
+
119
+ export async function permissionsCheck(host) {
120
+ try {
121
+ const status = await inspectPermissions(host.settingsPath);
122
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
123
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
124
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
125
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
126
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
127
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
128
+ `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
129
+ } catch (err) {
130
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
131
+ }
132
+ }
@@ -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
+ }