@lyupro/codex-bridge 0.6.10 → 0.6.12

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 (60) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +16 -4
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +18 -2
  5. package/cli/dispatcher-model-check.mjs +61 -0
  6. package/cli/doctor-host-side.mjs +117 -110
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor.mjs +19 -4
  9. package/cli/handback-witness-check.mjs +30 -20
  10. package/cli/hosts.mjs +2 -10
  11. package/cli/install-owners.mjs +1 -4
  12. package/cli/install-record.mjs +7 -1
  13. package/cli/inventory-confirm.mjs +75 -0
  14. package/cli/inventory-transition.mjs +4 -2
  15. package/cli/manifest.mjs +6 -6
  16. package/package.json +2 -2
  17. package/src/agents/codex-advisor.md +25 -119
  18. package/src/agents/codex-build.md +15 -133
  19. package/src/agents/codex-review.md +10 -109
  20. package/src/agents/codex-scout.md +11 -130
  21. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  22. package/src/home/hooks/live-runs.mjs +0 -41
  23. package/src/home/hooks/order-gate.mjs +59 -40
  24. package/src/home/hooks/reply-guard.mjs +52 -65
  25. package/src/home/hooks/reply-identity.mjs +64 -0
  26. package/src/home/hooks/reply-verdicts.mjs +31 -27
  27. package/src/home/hooks/reply-witness.mjs +48 -0
  28. package/src/home/lib/claude-layout.mjs +14 -0
  29. package/src/home/lib/dispatcher-call.mjs +171 -0
  30. package/src/home/lib/dispatcher-command.mjs +18 -56
  31. package/src/home/lib/dispatcher-gate.mjs +4 -0
  32. package/src/home/lib/dispatcher-model.mjs +112 -0
  33. package/src/home/lib/dispatcher-order.mjs +21 -22
  34. package/src/home/lib/dispatcher-pin.mjs +60 -0
  35. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  36. package/src/home/lib/dispatcher-stop.mjs +11 -2
  37. package/src/home/lib/handback-witness.mjs +137 -48
  38. package/src/home/lib/home-registry.mjs +7 -0
  39. package/src/home/lib/install-owner-roots.mjs +55 -0
  40. package/src/home/lib/meta/paths.mjs +1 -1
  41. package/src/home/lib/meta/reply.mjs +2 -1
  42. package/src/home/lib/model-family.mjs +38 -0
  43. package/src/home/lib/observation-ledger.mjs +145 -0
  44. package/src/home/lib/order-schema.mjs +204 -0
  45. package/src/home/lib/order-values.mjs +46 -0
  46. package/src/home/lib/runner/args.mjs +140 -231
  47. package/src/home/lib/runner/attach.mjs +3 -3
  48. package/src/home/lib/runner/continuation.mjs +174 -192
  49. package/src/home/lib/runner/launcher.mjs +9 -9
  50. package/src/home/lib/runner/order-options.mjs +30 -0
  51. package/src/home/lib/runner/order-owner.mjs +2 -1
  52. package/src/home/lib/runner/pass-gate.mjs +11 -9
  53. package/src/home/lib/runner/preflight.mjs +4 -3
  54. package/src/home/lib/runner/scope-check.mjs +11 -10
  55. package/src/home/lib/runner/task-input.mjs +23 -10
  56. package/src/home/lib/subagent-model-env.mjs +44 -0
  57. package/src/home/lib/task-header.mjs +158 -127
  58. package/src/home/lib/untyped-stop.mjs +30 -0
  59. package/src/home/lib/required-inputs.mjs +0 -301
  60. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Reads the INSTALLED PACKAGE CONTRACT, unanimous over recorded owners (Plan_67 D10),
3
+ * never a reconstruction of the definition the host selected or its precedence rules.
4
+ * One broken owner makes the shared pin undetermined; this boundary is synchronous and read-only.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { claudePaths } from './claude-layout.mjs';
9
+ import { CLI_NAMES } from './cli-names.mjs';
10
+ import { parseFrontmatter } from './frontmatter.mjs';
11
+ import { readOwnerRoots } from './install-owner-roots.mjs';
12
+ import { modelFamily } from './model-family.mjs';
13
+
14
+ export function readDispatcherPin({ brandRoot, agentType }) {
15
+ const owners = readOwnerRoots({ brandRoot });
16
+ if (owners.problem) return { family: null, reasons: [owners.detail] };
17
+
18
+ const reasons = [];
19
+ const pins = [];
20
+ for (const root of owners.roots) {
21
+ const file = path.join(claudePaths(root).agentsDir, `${agentType}.md`);
22
+ let content;
23
+ try {
24
+ content = fs.readFileSync(file, 'utf8');
25
+ } catch (error) {
26
+ const problem = error.code === 'ENOENT' ? 'is missing' : 'cannot be read';
27
+ reasons.push(`Dispatcher file "${file}" ${problem}; repair with ${CLI_NAMES[0]} install --host "${root}".`);
28
+ continue;
29
+ }
30
+
31
+ let frontmatter;
32
+ try {
33
+ frontmatter = parseFrontmatter(content);
34
+ } catch {
35
+ frontmatter = null;
36
+ }
37
+ if (frontmatter === null) {
38
+ reasons.push(`The frontmatter in "${file}" is unreadable.`);
39
+ continue;
40
+ }
41
+ if (frontmatter.name !== agentType) {
42
+ reasons.push(`Dispatcher file "${file}" does not declare the required frontmatter name "${agentType}".`);
43
+ continue;
44
+ }
45
+ const family = modelFamily(frontmatter.model);
46
+ if (family === null) {
47
+ reasons.push(`Dispatcher file "${file}" has no parseable model family in its frontmatter model.`);
48
+ continue;
49
+ }
50
+ pins.push({ root, family });
51
+ }
52
+
53
+ if (new Set(pins.map(({ family }) => family)).size > 1) {
54
+ const disagreement = pins.map(({ root, family }) => `"${root}" -> ${family}`).join(', ');
55
+ reasons.push(`Installed dispatcher model families disagree across owners: ${disagreement}.`);
56
+ }
57
+ return reasons.length
58
+ ? { family: null, reasons }
59
+ : { family: pins[0].family, roots: owners.roots };
60
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Owns only the dispatcher protocol text shared by every dispatcher prompt.
3
+ * Plan_63 D11: four hand-written copies drifted; build even requested a continuation
4
+ * flag absent from the canonical command. Render the command from its own assembler.
5
+ */
6
+ import { renderRunCommandTemplate } from './dispatcher-command.mjs';
7
+ import { TASK_FILE_INPUT } from './dispatcher-call.mjs';
8
+
9
+ export function renderDispatcherProtocol(agentType) {
10
+ const command = renderRunCommandTemplate(agentType);
11
+ return `## Required dispatcher input
12
+
13
+ The call is exactly one line: \`${TASK_FILE_INPUT.label}: <task-file path from the orchestrator>\`.
14
+ The path must be absolute and name a file the orchestrator wrote with its file tool.
15
+ The dispatcher never creates, reads or rewrites it: writing it from the shell brings back
16
+ the permission prompt this file channel exists to remove.
17
+ The whole order — order id, grants and every other value — lives in that file's header.
18
+ The runner checks the header, never the dispatcher. The dispatcher never adds, removes or
19
+ reorders anything on the command line because of what the call or the file says.
20
+ No path in the call means run nothing and return
21
+ \`FAIL — could not start the Codex run: no task file in the call\`, the form the no-self-execution rule gives.
22
+
23
+ ## When the host refuses the command
24
+
25
+ If the host refuses to run \`codex-bridge run\` — a permission prompt, a classifier denial, anything
26
+ that stops the command — that refusal is your final answer. Report \`FAIL\`, name the task file from the call,
27
+ and state the one correction: the operator runs \`codex-bridge install\`, which grants the permission
28
+ rule this package needs.
29
+
30
+ You are forbidden to look for a way around it. Specifically, and without exception:
31
+
32
+ - never call \`run-codex.mjs\`, or any file inside the installed package, by path;
33
+ - never start the runner through \`node\`, \`npx\`, \`sh\`, \`bash\` or any other interpreter;
34
+ - never retry the same call in PowerShell because Bash refused it, or the reverse;
35
+ - never split the call over more than one line, and never add a pipe, a semicolon or a redirect;
36
+ - never advise the operator to grant a permission rule on an internal file — a rule on anything but
37
+ the package command undoes the very design that makes this call permission-stable.
38
+
39
+ Every one of those forms was removed on purpose: a host matches a permission rule against the
40
+ beginning of the final command line, so an interpreter, a path or a continuation makes the call
41
+ unmatchable by construction. Reaching for one does not rescue the run; it guarantees the refusal
42
+ and asks the operator to make it permanent. On 2026-08-15 an order in another repository did all
43
+ three in sequence and ended by telling its operator to grant a rule on \`run-codex.mjs\`.
44
+
45
+ ## The only thing you do
46
+
47
+ \`\`\`bash
48
+ ${command}
49
+ \`\`\`
50
+
51
+ Replace the placeholder with the path from the call, nothing else.
52
+ The first call starts the run and returns at once with \`RUN=<path> order-id=<id>\` and a
53
+ \`STARTED\` line. To get the verdict, run the identical command again. That call does not start
54
+ a second run or cost quota: it attaches to the run already in flight, prints
55
+ \`ATTACH=<path> order-id=<id> started=<time>\`, blocks until the verdict exists and prints it.
56
+ Give the attaching call \`timeout: 1800000\` (30 minutes). A real run takes 20-25 minutes,
57
+ which is normal, not a hang. If the ceiling kills it, run the identical command again:
58
+ it attaches to the same run and keeps waiting.
59
+
60
+ Background execution (\`run_in_background\`, \`&\`, \`nohup\`) is prohibited, and so is inventing
61
+ a report from memory. Never add a flag: the host allows exactly the one canonical command
62
+ and refuses every other.
63
+
64
+ A runner refusal — an order id collision, a header problem or a grant problem — is the whole
65
+ answer: return it as \`FAIL\` with the runner's text. Never retry with a changed command or another
66
+ task file. The next pass is assigned only by the orchestrator, never chosen by you.
67
+ The order id in the task-file header is issued by the orchestrator and is what makes a repeat
68
+ harmless; a changed task file or command turns a repeat into a second paid run.
69
+ On 2026-08-03 one order became six runs exactly this way, and four of them were never accounted for
70
+ at all. A second writing run in one worktree is refused anyway — the tree is shared and has no
71
+ isolation.
72
+
73
+ **Your response = the exact stdout of the attaching call**: the \`ATTACH=<path>\` line and the status
74
+ block below it. Do not add or remove anything: no preamble, explanations, or retelling of the diff.
75
+ The report is in \`report.md\`; the orchestrator will read it. Do not make commits: that is the
76
+ operator's decision. The \`STARTED\` output of the first call is not a result and is never the response.
77
+ When the first call prints no \`RUN=\` line — a runner refusal or an \`UNAVAILABLE\` block, with no run
78
+ folder created — there is nothing to attach to: that output, verbatim, is the whole response, and
79
+ the identical command is not run again.
80
+
81
+ The only allowed final response is this exact stdout. Wording such as "the run has started,
82
+ waiting for completion," "I will wait for a notification," or "Monitor started in the background" is
83
+ prohibited in any form: the subagent ceases to exist immediately after responding, nobody can wait,
84
+ and the orchestrator gets a promise instead of a result. A real case: the dispatcher gave exactly
85
+ such a response during a live run — the worktree remained busy, and this could only be discovered
86
+ by checking \`status.json\` manually.
87
+ Inventing any outcome the runner did not print is equally prohibited. On 2026-08-13 a dispatcher said
88
+ \`FAIL — could not get the Codex run result because of an architectural environment limitation\` while
89
+ that run's \`status.json\` already said \`state=finished\`, \`status=OK\`.
90
+ `;
91
+ }
@@ -12,21 +12,30 @@ export function transcriptToolUses(transcriptPath) {
12
12
  }
13
13
 
14
14
  const toolUses = [];
15
+ let complete = true;
15
16
  for (const line of source.split(/\r?\n/)) {
17
+ if (!line.trim()) continue;
16
18
  let record;
17
19
  try {
18
20
  record = parseJsonText(transcriptPath, line);
19
21
  } catch {
22
+ complete = false;
20
23
  continue;
21
24
  }
22
25
  if (record?.type !== 'assistant' && record?.message?.role !== 'assistant') continue;
23
26
  const content = record.message?.content;
24
27
  if (!Array.isArray(content)) continue;
25
28
  for (const block of content) {
26
- if (block?.type === 'tool_use') toolUses.push({ id: block.id, name: block.name });
29
+ if (block?.type === 'tool_use') {
30
+ const toolUse = { id: block.id, name: block.name };
31
+ if (block.name === 'Bash' && typeof block.input?.command === 'string') {
32
+ toolUse.command = block.input.command;
33
+ }
34
+ toolUses.push(toolUse);
35
+ }
27
36
  }
28
37
  }
29
- return toolUses;
38
+ return { toolUses, complete };
30
39
  }
31
40
 
32
41
  export function decideDispatcherStop({ state, toolUses }) {
@@ -1,73 +1,162 @@
1
- /** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
2
- import fs from 'node:fs';
1
+ /** records whether hosts and dispatchers keep the handback contract, per cause, over the observation ledger
2
+ * Plan_67 D8 cures the eternal-warning defect with cause-specific evidence; D9 keeps old untyped
3
+ * alarms without dispatcher evidence in legacy history, never as confirmed violations.
4
+ */
3
5
  import path from 'node:path';
4
- import { writeHomeJsonAtomic } from './atomic-json.mjs';
5
- import { parseJsonText } from './json-file.mjs';
6
- import { withHomeFileLock } from './file-lock.mjs';
7
- import { stateDirWriter } from './home-write.mjs';
8
6
  import { transcriptHostVersion } from './host-version.mjs';
7
+ import {
8
+ emptyLedger, normalizeLedger, readLedgerFile, reduceObservation, updateLedgerFile,
9
+ } from './observation-ledger.mjs';
9
10
 
10
11
  export const WITNESS_FILE = 'handback-witness.json';
12
+ export const WITNESS_CAUSES = Object.freeze(['missing-agent-type', 'missing-ids', 'tools-outside-gate']);
11
13
 
12
14
  export async function witnessHostVersion(payload) {
13
15
  return transcriptHostVersion(payload?.transcript_path);
14
16
  }
15
17
 
16
- function normalizeWitness(record) {
17
- const alarmEntry = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
18
- && (entry.hostVersion === null || typeof entry.hostVersion === 'string')
19
- && typeof entry.at === 'string' && typeof entry.detail === 'string';
20
- if (!record || typeof record !== 'object' || Array.isArray(record) || !Array.isArray(record.alarms)) return null;
21
- if (record.alarms.every(alarmEntry) && record.lastSeen && typeof record.lastSeen === 'object'
22
- && !Array.isArray(record.lastSeen) && !Object.hasOwn(record.lastSeen, 'sdkVersion')
23
- && !Object.hasOwn(record.lastSeen, 'at') && Object.values(record.lastSeen).every((at) => typeof at === 'string')) return record;
24
- const legacy = record.lastSeen === null || (record.lastSeen && typeof record.lastSeen === 'object'
25
- && !Array.isArray(record.lastSeen) && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
26
- const legacyAlarm = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
18
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
19
+ const hostMap = (value) => object(value) && Object.values(value).every((at) => typeof at === 'string');
20
+ const alarmEntry = (entry) => object(entry)
21
+ && (entry.hostVersion === null || typeof entry.hostVersion === 'string')
22
+ && typeof entry.at === 'string' && typeof entry.detail === 'string';
23
+
24
+ function emptyWitness() {
25
+ return { version: 2, ledger: emptyLedger(), intercepted: {}, legacy: [] };
26
+ }
27
+
28
+ export function witnessKey({ cause, hostVersion, agentType }) {
29
+ if (!WITNESS_CAUSES.includes(cause)) throw new TypeError('cause must be a witness cause');
30
+ if (hostVersion != null && typeof hostVersion !== 'string') {
31
+ throw new TypeError('hostVersion must be a string or null');
32
+ }
33
+ const host = hostVersion || 'unknown';
34
+ if (cause === 'missing-agent-type') return `${host}|${cause}`;
35
+ if (typeof agentType !== 'string' || agentType.length === 0) {
36
+ throw new TypeError('agentType is required for this witness cause');
37
+ }
38
+ return `${host}|${agentType}|${cause}`;
39
+ }
40
+
41
+ // Preserve the previous normalizer's accepted host-map and SDK shapes. SDK versions are not hosts.
42
+ function normalizeOldWitness(record) {
43
+ if (!object(record) || !Array.isArray(record.alarms)) return null;
44
+ if (record.alarms.every(alarmEntry) && hostMap(record.lastSeen)
45
+ && !Object.hasOwn(record.lastSeen, 'sdkVersion') && !Object.hasOwn(record.lastSeen, 'at')) return record;
46
+ const legacy = record.lastSeen === null || (object(record.lastSeen)
47
+ && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
48
+ const legacyAlarm = (entry) => object(entry)
27
49
  && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
28
50
  && typeof entry.detail === 'string';
29
- if (legacy && record.alarms.every(legacyAlarm)) return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
51
+ if (legacy && record.alarms.every(legacyAlarm)) {
52
+ return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
53
+ }
30
54
  return null;
31
55
  }
32
56
 
33
- export function readHandbackWitness({ stateDir }) {
34
- const file = path.join(stateDir, WITNESS_FILE);
35
- let source;
36
- try {
37
- source = fs.readFileSync(file, 'utf8');
38
- } catch (error) {
39
- if (error.code === 'ENOENT') return { lastSeen: {}, alarms: [] };
40
- return { corrupt: true };
57
+ function normalizeWitnessLedger(parsed) {
58
+ const ledger = normalizeLedger(parsed);
59
+ if (!ledger) return null;
60
+ for (const entry of Object.values(ledger.entries)) {
61
+ for (const observation of [entry.lastObservation, entry.lastViolation, entry.lastMatch, ...entry.history]) {
62
+ if (observation === null) continue;
63
+ const data = observation.data;
64
+ if (!data || Object.getPrototypeOf(data) !== Object.prototype || !WITNESS_CAUSES.includes(data.cause)
65
+ || !(data.hostVersion === null || typeof data.hostVersion === 'string')
66
+ || !(data.agentType === null || typeof data.agentType === 'string')) return null;
67
+ }
41
68
  }
42
- try {
43
- const record = parseJsonText(file, source);
44
- return normalizeWitness(record) || { corrupt: true };
45
- } catch {
46
- return { corrupt: true };
69
+ return ledger;
70
+ }
71
+
72
+ export function migrateWitness(parsed) {
73
+ if (object(parsed) && Object.hasOwn(parsed, 'version')) {
74
+ return parsed.version === 2 && normalizeWitnessLedger(parsed.ledger) && hostMap(parsed.intercepted)
75
+ && Array.isArray(parsed.legacy) && parsed.legacy.every((entry) => alarmEntry(entry)
76
+ && ['legacy-untyped-unverified', 'unclassified'].includes(entry.disposition)) ? parsed : null;
77
+ }
78
+ const old = normalizeOldWitness(parsed);
79
+ if (!old) return null;
80
+ const record = { ...emptyWitness(), intercepted: { ...old.lastSeen } };
81
+ const alarms = [...old.alarms].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
82
+ for (const { at, hostVersion, detail } of alarms) {
83
+ if (/^host omitted agent_type for agent \S+$/.test(detail)) {
84
+ // D9: old stops lack dispatcher evidence; reclassification must not manufacture a violation.
85
+ record.legacy.push({ at, hostVersion, detail, disposition: 'legacy-untyped-unverified' });
86
+ continue;
87
+ }
88
+ const ids = /^host omitted session_id or agent_id for (\S+)$/.exec(detail);
89
+ const tools = /^(\S+) (\S+): (.+) outside the dispatcher gate$/.exec(detail);
90
+ if (!ids && !tools) {
91
+ record.legacy.push({ at, hostVersion, detail, disposition: 'unclassified' });
92
+ continue;
93
+ }
94
+ const cause = ids ? 'missing-ids' : 'tools-outside-gate';
95
+ const agentType = (ids || tools)[1];
96
+ try {
97
+ record.ledger = reduceObservation(record.ledger, {
98
+ key: witnessKey({ cause, hostVersion, agentType }), verdict: 'violation', at, detail,
99
+ data: { cause, hostVersion, agentType },
100
+ });
101
+ } catch {
102
+ return null;
103
+ }
47
104
  }
105
+ return record;
48
106
  }
49
107
 
50
- function timestamp(now) {
51
- return new Date(now).toISOString();
108
+ // readLedgerFile returns an empty ledger for ENOENT, even when its normalizer is an adapter.
109
+ function witnessRecord(record) {
110
+ return record.version !== 2 && record.format === 1 ? emptyWitness() : record;
52
111
  }
53
112
 
113
+ export function readHandbackWitness({ stateDir }) {
114
+ return witnessRecord(readLedgerFile(path.join(stateDir, WITNESS_FILE), { normalize: migrateWitness }));
115
+ }
54
116
 
55
- export async function recordHandbackWitness({ stateDir, kind, hostVersion, detail = '', now = new Date() }) {
56
- if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
117
+ function validateHost(hostVersion) {
57
118
  if (hostVersion !== null && typeof hostVersion !== 'string') {
58
119
  throw new TypeError('hostVersion must be a string or null');
59
120
  }
60
- const file = path.join(stateDir, WITNESS_FILE);
61
- // Plan_65 B6: the witness, its lock and temporaries are the registered handback-witness artifact.
62
- const writer = stateDirWriter(stateDir);
63
- return withHomeFileLock(writer, 'handback-witness', `${file}.lock`, async () => {
64
- const current = readHandbackWitness({ stateDir });
65
- if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
66
- const at = timestamp(now);
67
- const record = kind === 'seen'
68
- ? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
69
- : { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
70
- writeHomeJsonAtomic(writer, 'handback-witness', file, record);
71
- return record;
72
- }, { description: 'handback witness' });
121
+ }
122
+
123
+ export async function recordWitnessObservation({
124
+ stateDir, cause, hostVersion, agentType = null, verdict, detail = '', now = new Date(),
125
+ }) {
126
+ return recordWitnessObservations({
127
+ stateDir, observations: [{ cause, hostVersion, agentType, verdict, detail }], now,
128
+ });
129
+ }
130
+
131
+ export async function recordWitnessObservations({ stateDir, observations, now = new Date() }) {
132
+ const prepared = observations.map(({ cause, hostVersion, agentType = null, verdict, detail = '' }) => {
133
+ validateHost(hostVersion);
134
+ const key = witnessKey({ cause, hostVersion, agentType });
135
+ if (agentType !== null && (typeof agentType !== 'string' || agentType.length === 0)) {
136
+ throw new TypeError('agentType must be a non-empty string or null');
137
+ }
138
+ return { key, verdict, detail, data: { cause, hostVersion, agentType } };
139
+ });
140
+ const at = new Date(now).toISOString();
141
+ return updateLedgerFile({
142
+ stateDir, id: 'handback-witness', file: path.join(stateDir, WITNESS_FILE), normalize: migrateWitness,
143
+ update: (current) => {
144
+ const record = witnessRecord(current);
145
+ return { ...record, ledger: prepared.reduce((ledger, observation) =>
146
+ reduceObservation(ledger, { ...observation, at }), record.ledger) };
147
+ },
148
+ });
149
+ }
150
+
151
+ export async function recordInterceptedAttempt({ stateDir, hostVersion, now = new Date() }) {
152
+ validateHost(hostVersion);
153
+ const at = new Date(now).toISOString();
154
+ return updateLedgerFile({
155
+ stateDir, id: 'handback-witness', file: path.join(stateDir, WITNESS_FILE), normalize: migrateWitness,
156
+ update: (current) => {
157
+ const record = witnessRecord(current);
158
+ // D8: even denied handbacks are intercepted, so this timestamp is never match evidence.
159
+ return { ...record, intercepted: { ...record.intercepted, [hostVersion || 'unknown']: at } };
160
+ },
161
+ });
73
162
  }
@@ -83,6 +83,13 @@ const entries = [
83
83
  primary: ['state/handback-witness.json'],
84
84
  sides: ['lock', 'atomic-temporary'],
85
85
  },
86
+ {
87
+ id: 'dispatcher-model',
88
+ removal: 'purge-only',
89
+ consequence: 'Doctor forgets which model each dispatcher type was last observed on and every model violation it recorded.',
90
+ primary: ['state/dispatcher-model.json'],
91
+ sides: ['lock', 'atomic-temporary'],
92
+ },
86
93
  {
87
94
  id: 'host-observations',
88
95
  removal: 'purge-only',
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Reads which host roots own this home from the installation record, read-only, for hooks.
3
+ * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { homeArtifact } from './home-registry.mjs';
8
+ import { parseJsonText } from './json-file.mjs';
9
+
10
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ export function isFormat2(parsed) {
13
+ return isObject(parsed) && parsed.format === 2;
14
+ }
15
+
16
+ export function readOwnerRoots({ brandRoot }) {
17
+ const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
+ let raw;
19
+ try {
20
+ raw = fs.readFileSync(file, 'utf8');
21
+ } catch (error) {
22
+ return error.code === 'ENOENT'
23
+ ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
+ : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
+ }
26
+
27
+ let parsed;
28
+ try {
29
+ parsed = parseJsonText(file, raw);
30
+ } catch {
31
+ return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
+ }
33
+ if (!isFormat2(parsed)) {
34
+ return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
+ }
36
+ // Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
37
+ if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
38
+ return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
39
+ }
40
+ if (parsed.inventory !== 'complete') {
41
+ // Plan_67 D11: match cli/inventory-confirm.mjs INVENTORY_CONFIRM_COMMAND; hooks cannot import cli/.
42
+ return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory; if the recorded hosts are all the hosts using this home, run codex-bridge inventory confirm.` };
43
+ }
44
+ if (Object.hasOwn(parsed, 'legacy')) {
45
+ return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
46
+ }
47
+ const roots = [];
48
+ for (const owner of Object.values(parsed.owners)) {
49
+ if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
50
+ return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
51
+ }
52
+ roots.push(owner.root);
53
+ }
54
+ return { roots: roots.sort() };
55
+ }
@@ -114,7 +114,7 @@ export const samePath = (declared, touched) => {
114
114
  };
115
115
 
116
116
  /**
117
- * One --scope pattern as a regular expression. Deliberately a small glob dialect and not a
117
+ * One scope pattern as a regular expression. Deliberately a small glob dialect and not a
118
118
  * dependency: `**` crosses directory boundaries, `*` and `?` stop at `/`, matching is
119
119
  * case-insensitive because Windows paths arrive in whatever case Codex felt like using.
120
120
  * A slash right after `**` is optional, so `src/**` covers `src/a.ts` too — otherwise the
@@ -15,6 +15,7 @@ import { AGENTS as AGENT_DEFINITIONS } from '../agents.mjs';
15
15
  import { changedPaths, line, readText } from './paths.mjs';
16
16
  import { splitRunChanges } from './environment.mjs';
17
17
  import { scoutCoverage } from './verdict.mjs';
18
+ import { orderInputName } from '../order-schema.mjs';
18
19
 
19
20
  /** What the run itself changed, and what the tooling around it changed while it worked. */
20
21
  const runChanges = (runDir) =>
@@ -143,7 +144,7 @@ function advisorReply(ctx) {
143
144
  const risks = r.predicted_risks || [];
144
145
  return [
145
146
  r.sufficient
146
- ? 'OK — scope: sufficient; continue this run with --phase advise'
147
+ ? `OK — scope: sufficient; continue this run with ${orderInputName('phase')} advise`
147
148
  : `OK — scope: insufficient; ${missing.length} paths named`,
148
149
  ...(missing.length ? [`Missing: ${line(missing.join(', '), 160)}`] : []),
149
150
  `Predicted risks: ${risks.length}${risks[0] ? ` — ${risks[0].id} ${line(risks[0].risk, 120)}` : ''}`,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Parses model families by id structure, Plan_67 D5, without a model id literal in code:
3
+ * an id table would become stale with every model release and create false mismatch warnings.
4
+ * D5 (b) treats unparsed ids as no evidence, rather than as matches or violations.
5
+ */
6
+
7
+ // These are host aliases, not model ids or family pins (Plan_67 D5).
8
+ export const NON_FAMILY_ALIASES = Object.freeze(['inherit', 'default', 'opusplan']);
9
+
10
+ const isAsciiLetters = (value) => value.length > 0 && !/[^A-Za-z]/.test(value);
11
+
12
+ export function modelFamily(value) {
13
+ if (typeof value !== 'string') return null;
14
+ const tokens = value.replace(/\[[^\[\]]*\]$/, '').split(/[-.@]/);
15
+ const claudeIndex = tokens.findIndex((token) => token.toLowerCase() === 'claude');
16
+ if (claudeIndex !== -1) {
17
+ const family = tokens.slice(claudeIndex + 1).find(isAsciiLetters);
18
+ return family ? family.toLowerCase() : null;
19
+ }
20
+ if (tokens.length !== 1 || !isAsciiLetters(tokens[0])) return null;
21
+ const family = tokens[0].toLowerCase();
22
+ return NON_FAMILY_ALIASES.includes(family) ? null : family;
23
+ }
24
+
25
+ export function compareModelFamilies({ pinFamily, modelIds }) {
26
+ const families = new Set();
27
+ let unparsed = 0;
28
+ for (const id of modelIds) {
29
+ const family = modelFamily(id);
30
+ if (family === null) unparsed += 1;
31
+ else families.add(family);
32
+ }
33
+ const parsed = [...families];
34
+ const verdict = pinFamily === null || parsed.length === 0
35
+ ? 'undetermined'
36
+ : parsed.some((family) => family !== pinFamily) ? 'violation' : 'match';
37
+ return { verdict, parsed, unparsed };
38
+ }