@lyupro/codex-bridge 0.6.9 → 0.6.11

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 (88) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +15 -4
  3. package/cli/dispatcher-model-check.mjs +59 -0
  4. package/cli/doctor-host-side.mjs +2 -1
  5. package/cli/doctor-installation.mjs +111 -111
  6. package/cli/doctor.mjs +13 -5
  7. package/cli/handback-witness-check.mjs +24 -18
  8. package/cli/hosts.mjs +2 -10
  9. package/cli/install-owners.mjs +1 -4
  10. package/cli/install-record.mjs +2 -1
  11. package/cli/inventory-transition.mjs +1 -1
  12. package/cli/lifecycle-lock-inspect.mjs +3 -2
  13. package/cli/lifecycle-lock.mjs +19 -132
  14. package/cli/manifest.mjs +6 -6
  15. package/cli/read.mjs +20 -0
  16. package/cli/settings-merge.mjs +3 -29
  17. package/package.json +2 -2
  18. package/scripts/home-write-inventory.mjs +12 -2
  19. package/src/agents/codex-advisor.md +37 -121
  20. package/src/agents/codex-build.md +27 -135
  21. package/src/agents/codex-review.md +19 -111
  22. package/src/agents/codex-scout.md +21 -130
  23. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  24. package/src/home/hooks/live-runs.mjs +0 -41
  25. package/src/home/hooks/order-gate.mjs +80 -47
  26. package/src/home/hooks/reply-guard.mjs +52 -65
  27. package/src/home/hooks/reply-identity.mjs +64 -0
  28. package/src/home/hooks/reply-parser.mjs +1 -1
  29. package/src/home/hooks/reply-verdicts.mjs +31 -27
  30. package/src/home/hooks/reply-witness.mjs +48 -0
  31. package/src/home/lib/claude-layout.mjs +14 -0
  32. package/src/home/lib/command-path.mjs +32 -0
  33. package/src/home/lib/dispatcher-call.mjs +171 -0
  34. package/src/home/lib/dispatcher-command.mjs +18 -54
  35. package/src/home/lib/dispatcher-gate.mjs +4 -0
  36. package/src/home/lib/dispatcher-model.mjs +112 -0
  37. package/src/home/lib/dispatcher-order.mjs +21 -22
  38. package/src/home/lib/dispatcher-pin.mjs +60 -0
  39. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  40. package/src/home/lib/dispatcher-stop.mjs +11 -2
  41. package/src/home/lib/handback-witness.mjs +137 -48
  42. package/src/home/lib/home-registry.mjs +7 -0
  43. package/src/home/lib/install-owner-roots.mjs +53 -0
  44. package/src/home/lib/kernel-lock.mjs +171 -0
  45. package/src/home/lib/meta/chain.mjs +19 -8
  46. package/src/home/lib/meta/context-only.mjs +43 -0
  47. package/src/home/lib/meta/events.mjs +14 -2
  48. package/src/home/lib/meta/launch-rows.mjs +23 -0
  49. package/src/home/lib/meta/paths.mjs +1 -1
  50. package/src/home/lib/meta/reply.mjs +39 -4
  51. package/src/home/lib/meta/run-state.mjs +3 -0
  52. package/src/home/lib/meta/verdict.mjs +16 -8
  53. package/src/home/lib/model-family.mjs +38 -0
  54. package/src/home/lib/observation-ledger.mjs +145 -0
  55. package/src/home/lib/order-schema.mjs +204 -0
  56. package/src/home/lib/order-values.mjs +46 -0
  57. package/src/home/lib/run-codex.mjs +6 -3
  58. package/src/home/lib/runner/advise-carry.mjs +2 -1
  59. package/src/home/lib/runner/args.mjs +140 -220
  60. package/src/home/lib/runner/attach.mjs +24 -17
  61. package/src/home/lib/runner/build-evidence.mjs +29 -0
  62. package/src/home/lib/runner/codex-availability.mjs +50 -0
  63. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  64. package/src/home/lib/runner/continuation.mjs +174 -140
  65. package/src/home/lib/runner/exit-codes.mjs +13 -0
  66. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  67. package/src/home/lib/runner/flag-scan.mjs +165 -0
  68. package/src/home/lib/runner/git-state.mjs +0 -33
  69. package/src/home/lib/runner/launcher.mjs +237 -277
  70. package/src/home/lib/runner/order-claim.mjs +84 -0
  71. package/src/home/lib/runner/order-options.mjs +30 -0
  72. package/src/home/lib/runner/order-owner.mjs +19 -0
  73. package/src/home/lib/runner/pass-gate.mjs +111 -0
  74. package/src/home/lib/runner/preflight.mjs +26 -28
  75. package/src/home/lib/runner/prompts.mjs +7 -2
  76. package/src/home/lib/runner/question-kind.mjs +41 -0
  77. package/src/home/lib/runner/run-env.mjs +2 -1
  78. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  79. package/src/home/lib/runner/scope-check.mjs +31 -12
  80. package/src/home/lib/runner/task-input.mjs +34 -12
  81. package/src/home/lib/runner/worker-admission.mjs +43 -0
  82. package/src/home/lib/runner/worker.mjs +19 -18
  83. package/src/home/lib/subagent-model-env.mjs +44 -0
  84. package/src/home/lib/task-header.mjs +158 -0
  85. package/src/home/lib/untyped-stop.mjs +30 -0
  86. package/src/home/lib/write-meta.mjs +17 -5
  87. package/src/home/lib/required-inputs.mjs +0 -261
  88. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,138 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.11] - 2026-10-07
8
+
9
+ ### Changed
10
+
11
+ - **Breaking for orchestrator prompts:** the order gate refuses an explicit `model` on a dispatcher
12
+ call, even one matching the installed pin or `inherit`. Migration cost: drop `model` from dispatcher
13
+ calls in orchestrator prompts; the installed agent definition owns that setting. It also refuses a
14
+ foreign-family `CLAUDE_CODE_SUBAGENT_MODEL` on hosts where the environment overrides the pin
15
+ (before 2.1.251, or with `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1` from 2.1.257). Unknown host versions or
16
+ unparseable families are left to the stop observation. These checks precede prompt and grant exits,
17
+ so a continuation cannot bypass them.
18
+
19
+ - **Breaking for orchestrator prompts:** a dispatcher call is exactly one line, `task file: <absolute
20
+ path>`. The order — `order id`, `scope`, `scope new`, `phase`, `continue`/`retry`, `slug`, `effort`,
21
+ `changeset`, `repository` — goes in the header at the top of that file, and the dispatcher runs
22
+ `codex-bridge run --agent <type> --task-file "<path>"`. Any former label in the call is refused for
23
+ free with "moved to the task-file header" and the agent's header template. Migration cost: move the
24
+ label lines from the call into the task file's first lines. A task-file path containing `$`, `\\` or a
25
+ final `\` is refused, because the shell would rewrite it inside the command's double quotes and the
26
+ runner would open a different file than the one the gate checked; write it with forward slashes.
27
+
28
+ - The four dispatcher prompts share one protocol section rendered at install from the same command
29
+ assembler the order gate uses: the call is `task file:` only and the command is
30
+ `codex-bridge run --agent <type> --task-file "<path>"`. The hand-written label-to-flag tables and the
31
+ instruction to add a continuation flag are gone; the header labels are listed in each agent's
32
+ description. Migration cost: none beyond `codex-bridge update`, which reinstalls the prompts. A
33
+ first call that prints no `RUN=` line (a refusal or `UNAVAILABLE`) is itself the dispatcher's answer.
34
+
35
+ - A dispatcher call contains only exact `label: value` lines from the agent's input registry, in any
36
+ order, with blank lines allowed. Prose, unknown or another agent's labels, decorated or hyphenated
37
+ spellings, `--flag value` lines, duplicates, empty values, missing required labels, placeholders,
38
+ relative task-file paths and shell-unsafe values are refused for free before the dispatcher starts.
39
+ The refusal names the line, the exact spelling and every label of the agent, and says "Free text
40
+ belongs in the task file." Of 405 measured past calls, 47 began with prose, 112 carried lines outside
41
+ the registry, 18 passed values as `--order-id x`, and 1 duplicated a label.
42
+ Migration cost: a call written in an older form gets one free refusal naming the fix.
43
+
44
+ ### Added
45
+
46
+ - The reply guard observes `message.model` in the dispatcher's assistant transcript entries at
47
+ `SubagentStop`, before its handback exits, and compares model families against the installed
48
+ contract agreed by every recorded owner. On 2026-09-25 a hook's `(inherit)` label was mistaken for
49
+ the model used, and the VS Code panel showed the parent model while transcripts showed the pinned
50
+ family; neither display is model evidence. A mismatch raises an alarm in the same turn and enters
51
+ `state/dispatcher-model.json`.
52
+ The order gate warns on every later call of that dispatcher type until a later match on any host;
53
+ uncertainty does not clear the warning. It warns rather than refusing work for a leak whose cause
54
+ may be another hook or the host.
55
+ - `doctor` reports `dispatcherModel:<type>` rows for the current observed session host: the latest
56
+ violation is `fail` with exit 1, an undetermined observation is `warn`, and a match is `ok`, including
57
+ recovery after a violation. No observations is `ok`; recovered incidents and other hosts remain
58
+ history, without a permanent warning. An incomplete, unreadable or disagreeing installed contract
59
+ makes the comparison undetermined, rather than guessing which agent definition the host selected.
60
+
61
+ ### Fixed
62
+
63
+ - The handback witness keeps per-cause observations and violation history without a permanent
64
+ warning. Only later evidence disproving that cause clears it, and only the current observed session
65
+ host contributes active warnings. Intercepted handbacks, including refused attempts, are not proof
66
+ of recovery. Existing witness files migrate on their next write: old untyped alarms become
67
+ `legacy-untyped-unverified` history, not confirmed violations; `doctor` reads them without rewriting
68
+ the file. This fixes the 2026-09-27 case where clean handbacks on a newer host still left an old alarm
69
+ as the only visible signal.
70
+ - A subagent stop without `agent_type` alarms only with dispatcher evidence bound to that stop:
71
+ healthy, active dispatcher-gate state or an assistant Bash call running the standalone runner command
72
+ for a registered role. The 13 older alarms across nine host versions lacked that evidence. Without
73
+ it, a stop is undetermined, neither an accusation nor recovery, and a command's `--agent` does not
74
+ invent the missing host type.
75
+
76
+ - The reply guard identifies a dispatcher's run by a receipt — the run folder the runner itself
77
+ printed to that dispatcher, recorded once by the dispatcher gate — instead of the order id in the
78
+ call or the newest run on disk. A reply naming another folder, a reply with a verdict but no
79
+ receipt, and two different folders reported to one dispatcher are blocked; the last one also turns
80
+ the handback into a `FAIL`.
81
+ - `changeset:` reaches the runner, so `base:<branch>` and `commit:<sha>` review the requested changes
82
+ instead of silently reviewing uncommitted work.
83
+ - `scope-new:` is refused with the exact `scope new:` spelling; it can no longer be dropped or read as
84
+ `scope`.
85
+ - A `Repository root:` line is refused before the haiku dispatcher starts. It used to make the
86
+ dispatcher prepend `cd … &&` and leave without delegating.
87
+
88
+ ## [0.6.10] - 2026-10-04
89
+
90
+ ### Added
91
+
92
+ - `UNAVAILABLE` is a status of its own, with exit code `5`. Before any quota is spent, a bounded
93
+ `codex --version` and `codex login status` tell a missing or signed-out Codex from an unclear answer.
94
+ Only a missing or signed-out Codex refuses as `UNAVAILABLE`, without a run folder. After the start,
95
+ only a measured sample (a final `401 Unauthorized` turn failure) ends a run `UNAVAILABLE`. Any other
96
+ error stays `FAIL` with its raw text, so a lost subscription is never mistaken for a failed task.
97
+ - A failed pass can be repeated once: `retry: <failed run> — <reason>` in the task header, with `--continue`.
98
+ The retry is accepted only for the last run of the chain, with a finished non-`OK` verdict and a worker
99
+ proven dead. It keeps the order id, agent, phase and continuation base, and does not spend another
100
+ continuation of the order. The reply says `Attempt: N of this pass`. Refusals met at a failed last run
101
+ print a ready `retry:` line.
102
+ - One order id admits one paid worker. A launcher takes a kernel-held claim on the project's run store and
103
+ the exact order id (named pipe on Windows, abstract socket on Linux). It holds the claim until its worker is
104
+ registered, and the worker admits itself under the same claim before Codex starts. A second launch of the
105
+ same order attaches to the first run instead of paying again. A claim that stays busy for 60 seconds is a
106
+ free refusal, never a second start. On 2026-09-24 two launchers of one order started 2.8 s apart and
107
+ both paid.
108
+ - A scout question can be marked `[context-only]` by its orderer. Such questions are about startup
109
+ context and pass without commands. An answer must cite `startup:<source>`, and a repository `path:line`
110
+ read by no command fails as an unread address. `questions.json` records `{id, text, kind}`.
111
+ - The build reply adds a `Flags coverage: incomplete` line when the start content of a file could not be
112
+ judged. The status stays as it is.
113
+
114
+ ### Changed
115
+
116
+ - Every runner exit code comes from one table, and a guard ties it to the docs and the prompts:
117
+ `0` OK, `1` FAIL, `2` usage error, `3` LIMIT, `4` still running or no run (`--no-wait`), `5` UNAVAILABLE.
118
+ - Service lines of a task file live only in its header: consecutive `advice:`, `continue:` and `retry:`
119
+ lines from the first line. One parser serves the runner, the task hash and the `order-gate` hook. The task
120
+ hash covers the body only, and new runs record `task_hash_scheme: 2`. A label line below the header that
121
+ looks like metadata is refused for free, with its line number and the repair. **Orchestrators that write
122
+ `advice:` further down a build task must move it to the top.**
123
+ - TODO, FIXME, skip and only flags judge only the lines a build run added. Before the run, the start content
124
+ of every dirty and untracked file is recorded, and only that run's additions are compared. Before this
125
+ change, a run was accused of markers it found in the tree.
126
+ - A scope refusal names the flag the pattern came from (`--scope` or `--scope-new`). When git cannot list
127
+ the folder the run started in, the refusal says so instead of claiming a tracked file does not exist.
128
+
129
+ ### Fixed
130
+
131
+ - A grant line no longer changes the task hash, so a run named in `continue:` is found again under a new
132
+ order id (it used to answer "the current last run is none").
133
+ - An order owned by a run from before the task header is refused truthfully, not as a different task.
134
+ - `codex-bridge read` on a run refused before Codex started now names that refusal. It no longer says
135
+ the run predates the event stream.
136
+ - A reply no longer repeats its verdict (`OK — OK —`) when the model began its own summary with one.
137
+ - An unparsable scope pattern is named in its refusal instead of printing `undefined`.
138
+
7
139
  ## [0.6.9] - 2026-10-02
8
140
 
9
141
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **2570 automated tests: 2569 passing and 1 skipped**.
65
+ The current suite contains **3199 automated tests: 3198 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -93,12 +93,23 @@ Output on a healthy host (trimmed):
93
93
 
94
94
  `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
95
95
 
96
- `doctor` also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Handback sightings and alarms carry the transcript version, and sightings match only that exact version; an older SDK-based record counts as no sighting while preserving its alarms. If a subagent stop has an `agent_id` but no `agent_type`, the reply guard records an alarm and tells the session that the dispatcher gate cannot recognise dispatchers. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
96
+ `doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. The witness warns only for unresolved causes on the current observed session host; later disproving evidence clears the warning while retaining history. Intercepted handbacks are not recovery evidence, and old untyped alarms without dispatcher evidence remain unverified history. A stop missing `agent_type` alarms only with dispatcher evidence for that stop. Dispatcher model rows compare transcript-observed families with the installed contract: a latest violation is `fail` with exit 1, uncertainty is `warn`, and a match, including recovery, is `ok`; no observations is also `ok`. Other hosts and recovered incidents are history, not permanent warnings. See [Response guard](docs/overview.md#response-guard) for the evidence rules. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
97
97
 
98
98
  If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
99
99
 
100
+ The task file starts with the order header, a blank line, and the task text:
101
+
102
+ ```text
103
+ order id: order-42
104
+ repository: /abs/path/to/repository
105
+ scope: src/auth/**,tests/auth/**
106
+ advice: mechanical
107
+
108
+ Implement the authentication change and verify it with the tests named in the task.
109
+ ```
110
+
100
111
  ```bash
101
- codex-bridge run --agent codex-build --repo <repository> --order-id <id> --scope <globs> --task-file <absolute path>
112
+ codex-bridge run --agent codex-build --task-file "/abs/path/to/task.md"
102
113
  ```
103
114
 
104
115
  The trade-off is explicit: `order-gate` and `reply-guard` sit on the dispatcher call, so a direct run is unguarded and its result is judged by the worktree and the suite.
@@ -131,7 +142,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
131
142
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
132
143
  | `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
133
144
  | `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
134
- | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
145
+ | `run --agent <type> --task-file "<abs path>" [--no-wait]` | Start or attach to a delegated run through the permission-stable package command. The task-file header carries the whole order; its body carries the statement, scout questions and verification command. Its path must be absolute and use forward slashes; `$`, doubled backslashes and a final backslash are refused. Every other runner flag is a free refusal; stdin is not an input channel. |
135
146
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
136
147
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
137
148
  | `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]`<br>`prune --all-projects [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup, for one project or across the whole run store. |
@@ -0,0 +1,59 @@
1
+ /** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8 and D8 clarification 2026-10-07. */
2
+ import path from 'node:path';
3
+ import { DISPATCHER_MODEL_FILE } from '../src/home/lib/dispatcher-model.mjs';
4
+ import { entryState } from '../src/home/lib/observation-ledger.mjs';
5
+ import { check } from './doctor-format.mjs';
6
+
7
+ function observationSummary({ data, at }) {
8
+ return `${data.agentType}: observed families ${data.parsed.join(', ')}, pinned family ${data.pinFamily} at ${at}`;
9
+ }
10
+
11
+ function entryCheck(entry) {
12
+ const observation = entry.lastObservation;
13
+ const { data, verdict, at } = observation;
14
+ const key = `dispatcherModel:${data.agentType}`;
15
+ const state = entryState(entry);
16
+ // D4: only the latest confirmed violation fails; D8 keeps uncertainty from erasing history.
17
+ if (verdict === 'violation') {
18
+ return check(key, 'fail', `${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
19
+ }
20
+ if (verdict === 'undetermined') {
21
+ const reason = data.pinReasons?.length
22
+ ? data.pinReasons.join('; ') : 'no model family could be read from the transcript';
23
+ const unresolved = state === 'violation'
24
+ ? `; earlier violation (${observationSummary(entry.lastViolation)}) is not yet disproved` : '';
25
+ return check(key, 'warn', `${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
26
+ }
27
+ const history = state === 'recovered'
28
+ ? `; recovered from earlier violation (${observationSummary(entry.lastViolation)})` : '';
29
+ return check(key, 'ok', `${observationSummary(observation)}; matches the installed contract${history}.`);
30
+ }
31
+
32
+ export function dispatcherModelChecks({ record, hostVersion, stateDir }) {
33
+ if (record?.corrupt) {
34
+ return [check('dispatcherModel', 'warn',
35
+ `The dispatcher model record is unreadable; delete ${path.join(stateDir, DISPATCHER_MODEL_FILE)} to reset it.`)];
36
+ }
37
+ const entries = Object.values(record?.entries || {});
38
+ const current = entries.filter((entry) => entry.lastObservation.data.hostVersion === hostVersion);
39
+ const rows = current.map(entryCheck);
40
+ if (!rows.length) {
41
+ rows.push(check('dispatcherModel', 'ok', 'Not observed yet — recorded when a dispatcher stops on this host.'));
42
+ }
43
+ const hosts = new Map();
44
+ for (const entry of entries) {
45
+ const version = entry.lastObservation.data.hostVersion;
46
+ if (version === hostVersion) continue;
47
+ const counts = hosts.get(version) || { entries: 0, violations: 0 };
48
+ counts.entries += 1;
49
+ if (entryState(entry) === 'violation') counts.violations += 1;
50
+ hosts.set(version, counts);
51
+ }
52
+ // D8: other hosts are one history sentence, never additional active warning rows.
53
+ if (hosts.size) {
54
+ const history = [...hosts].map(([version, counts]) =>
55
+ `${version ?? 'unknown host'}: ${counts.entries} entries, ${counts.violations} unresolved violations`);
56
+ rows[0].value += ` History on other hosts (historical, not current): ${history.join('; ')}.`;
57
+ }
58
+ return rows;
59
+ }
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
11
  import { check } from './doctor-format.mjs';
12
12
  import { hasPackageMarks } from './host-inspection.mjs';
13
- import { imageFingerprint, isFormat2 } from './install-owners.mjs';
13
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
+ import { imageFingerprint } from './install-owners.mjs';
14
15
 
15
16
  // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
16
17
  export function ownerEntry(rawRecord, host) {
@@ -1,111 +1,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 './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
+ 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
+ }
package/cli/doctor.mjs CHANGED
@@ -19,11 +19,13 @@ import {
19
19
  } from './doctor-installation.mjs';
20
20
  import { hookChecks } from './doctor-hooks.mjs';
21
21
  import { contractStatus, readHostContract } from './host-contract.mjs';
22
- import { handbackWitnessStatus } from './handback-witness-check.mjs';
22
+ import { handbackWitnessStatus } from './handback-witness-check.mjs';
23
+ import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
23
24
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
24
25
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
25
26
  import { PROBE_COMMAND } from './host-contract.mjs';
26
- import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
27
+ import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
28
+ import { readDispatcherModel } from '../src/home/lib/dispatcher-model.mjs';
27
29
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
28
30
  import { readHostObservations } from '../src/home/lib/host-observations.mjs';
29
31
  import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
@@ -82,7 +84,8 @@ export async function diagnose({
82
84
  launcherProbe,
83
85
  currentPackage,
84
86
  contractRecord,
85
- handbackWitnessRecord,
87
+ handbackWitnessRecord,
88
+ dispatcherModelRecord,
86
89
  dispatcherContractRecord,
87
90
  hostVersion,
88
91
  observations,
@@ -162,7 +165,12 @@ export async function diagnose({
162
165
  hostVersion: detectedHostVersion,
163
166
  stateDir,
164
167
  });
165
- checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
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,
173
+ }));
166
174
  const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
167
175
  checks.push(...dispatcherContractChecks({
168
176
  record: dispatcherRecord,
@@ -190,7 +198,7 @@ export async function diagnose({
190
198
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
191
199
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
192
200
  || projectRuns.status === 'fail'
193
- || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
201
+ || checks.some((item) => /^(hook|dispatcherContract|dispatcherModel):/.test(item.key) && item.status === 'fail')
194
202
  || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
195
203
  checks,
196
204
  record,
@@ -1,31 +1,37 @@
1
1
  /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
2
2
  import path from 'node:path';
3
3
  import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
+ import { entryState } from '../src/home/lib/observation-ledger.mjs';
4
5
 
5
6
  export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
6
7
  if (record?.corrupt) {
7
8
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
8
9
  }
9
- if (record?.alarms?.length > 0) {
10
- const alarm = record.alarms.at(-1);
10
+ const entries = Object.values(record?.ledger?.entries || {});
11
+ const violations = entries.filter((entry) => entryState(entry) === 'violation'
12
+ && entry.lastObservation.data.hostVersion === hostVersion);
13
+ if (violations.length > 0) {
14
+ const details = violations.map((entry) => {
15
+ const { data, detail, at } = entry.lastViolation;
16
+ return `${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
17
+ });
11
18
  return {
12
- state: 'alarm',
13
- message: `${record.alarms.length} dispatcher alarm(s); newest on ${alarm.hostVersion ?? 'unknown host'}: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
19
+ 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.`,
14
21
  };
15
22
  }
16
- if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
17
- return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
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.`;
18
35
  }
19
-
20
- if (record.lastSeen[hostVersion]) {
21
- return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
22
- }
23
- const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
24
- if (newest) {
25
- return {
26
- state: 'stale',
27
- message: `Handback contract last seen on host ${newest} at ${iso}, not yet on host ${hostVersion}; it is recorded the next time a dispatcher runs in an interactive session on that host.`,
28
- };
29
- }
30
- return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
36
+ return { state: 'ok', message };
31
37
  }
package/cli/hosts.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
5
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
6
+ import { claudePaths } from '../src/home/lib/claude-layout.mjs';
6
7
 
7
8
  function repositoryRoot(start) {
8
9
  let current = path.resolve(start);
@@ -14,15 +15,6 @@ function repositoryRoot(start) {
14
15
  }
15
16
  }
16
17
 
17
- function claudePaths(root) {
18
- return {
19
- agentsDir: path.join(root, 'agents', 'codex-bridge'),
20
- commandsDir: path.join(root, 'commands', 'codex-bridge'),
21
- legacyAgentsDir: path.join(root, 'agents', 'codex'),
22
- legacyCommandsDir: path.join(root, 'commands', 'codex'),
23
- };
24
- }
25
-
26
18
  export function resolveHost({
27
19
  scope = 'user',
28
20
  host,