@lyupro/codex-bridge 0.6.6 → 0.6.8

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 (55) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -5
  3. package/bin/codex-bridge.mjs +186 -178
  4. package/cli/dispatcher-contract-record.mjs +51 -0
  5. package/cli/dispatcher-contract.mjs +90 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor-runs.mjs +55 -55
  9. package/cli/doctor.mjs +53 -5
  10. package/cli/handback-witness-check.mjs +31 -0
  11. package/cli/hook-targets.mjs +14 -6
  12. package/cli/hook.mjs +32 -23
  13. package/cli/host-contract.mjs +145 -135
  14. package/cli/install.mjs +23 -17
  15. package/cli/launcher-probe.mjs +38 -0
  16. package/cli/probe-contract.mjs +55 -70
  17. package/cli/probe-rig.mjs +53 -0
  18. package/cli/probe-target.mjs +33 -0
  19. package/cli/run-launcher.mjs +17 -0
  20. package/cli/session-hosts.mjs +51 -0
  21. package/cli/settings-merge.mjs +42 -35
  22. package/cli/uninstall.mjs +16 -3
  23. package/cli/update.mjs +45 -16
  24. package/package.json +4 -4
  25. package/scripts/run-tests.mjs +7 -0
  26. package/src/agents/codex-advisor.md +3 -4
  27. package/src/agents/codex-build.md +5 -5
  28. package/src/agents/codex-review.md +3 -3
  29. package/src/agents/codex-scout.md +3 -4
  30. package/src/home/hooks/dispatcher-gate.mjs +132 -0
  31. package/src/home/hooks/guard-tries.mjs +5 -6
  32. package/src/home/hooks/live-runs.mjs +1 -0
  33. package/src/home/hooks/order-gate.mjs +20 -5
  34. package/src/home/hooks/reply-guard.mjs +95 -10
  35. package/src/home/hooks/reply-verdicts.mjs +5 -0
  36. package/src/home/hooks/worktree-lock.mjs +108 -107
  37. package/src/home/lib/atomic-json.mjs +21 -0
  38. package/src/home/lib/brand-home.mjs +6 -0
  39. package/src/home/lib/dispatcher-command.mjs +79 -0
  40. package/src/home/lib/dispatcher-gate.mjs +95 -0
  41. package/src/home/lib/dispatcher-order.mjs +30 -8
  42. package/src/home/lib/dispatcher-state.mjs +111 -0
  43. package/src/home/lib/dispatcher-stop.mjs +52 -0
  44. package/src/home/lib/handback-witness.mjs +70 -0
  45. package/src/home/lib/hook-definitions.mjs +10 -0
  46. package/src/home/lib/hook-entry.mjs +20 -0
  47. package/src/home/lib/host-observations.mjs +57 -0
  48. package/src/home/lib/host-version.mjs +27 -0
  49. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  50. package/src/home/lib/required-inputs.mjs +1 -1
  51. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  52. package/src/home/lib/runner/git-state.mjs +1 -1
  53. package/src/home/lib/runner/preflight.mjs +6 -3
  54. package/src/home/lib/runner/prompts.mjs +8 -7
  55. package/src/home/lib/runner/schemas.mjs +33 -7
package/cli/uninstall.mjs CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  recordTarget,
11
11
  } from './manifest.mjs';
12
12
  import { removePermissionRules } from './permissions.mjs';
13
+ import { hostContractPath } from './host-contract.mjs';
14
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
13
15
  import {
14
16
  commandFor,
15
17
  removeHook,
@@ -33,6 +35,18 @@ function permissionOutput(host, removed, dryRun) {
33
35
  return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
34
36
  }
35
37
 
38
+ // Plan_62 D22: uninstall removes only recorded files, and the message used to name only runs and
39
+ // config.json — state/, the host measurement and conventions.md stayed behind unmentioned. Until
40
+ // Plan_65 derives this from a registry, the sentence names every kind the package writes while working.
41
+ function preservationText(host) {
42
+ return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
43
+ + `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
44
+ + `${host.brandConventionsPath}, the host measurement ${hostContractPath(host)} and runtime state in `
45
+ + `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, the host `
46
+ + 'observations, dispatcher contract verdicts, and hook diagnostics that hold the full hook input). Delete them by '
47
+ + 'hand for a complete removal.';
48
+ }
49
+
36
50
  function displayFile(file) {
37
51
  return `${file.root}/${file.path}`;
38
52
  }
@@ -62,8 +76,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
62
76
  const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
63
77
  const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
64
78
  const record = await readInstallRecord(host);
65
- const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
66
- + `configuration in ${host.brandConfigPath} are preserved.`;
79
+ const preservation = preservationText(host);
67
80
  if (!record) {
68
81
  return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
69
82
  }
@@ -92,7 +105,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
92
105
  }
93
106
  for (const hook of record.hooks) {
94
107
  const definition = definitionForRecordedHook(hook);
95
- lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
108
+ lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
96
109
  }
97
110
  lines.push('Would remove the installation record from the brand root.');
98
111
  lines.push(preservation);
package/cli/update.mjs CHANGED
@@ -17,11 +17,10 @@ import {
17
17
  rulesPlan,
18
18
  } from './manifest.mjs';
19
19
  import { targetMatches } from './copy.mjs';
20
- import { hookTargets } from './hook-targets.mjs';
20
+ import { hookTargets, recordHasHooks } from './hook-targets.mjs';
21
21
  import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
22
22
  import {
23
23
  commandFor,
24
- hookRegistration,
25
24
  inspectHook,
26
25
  removeHook,
27
26
  withSettingsRun,
@@ -29,6 +28,12 @@ import {
29
28
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
30
29
  import { claudeBoundary, removeEmptyLayout, removeEmptyParents } from './remove-layout.mjs';
31
30
 
31
+ const LEGACY_GUARD_LOG_FILES = [
32
+ 'codex-reply-guard.blocked.json',
33
+ 'codex-order-gate.last.json',
34
+ 'codex-reply-guard.last.json',
35
+ ];
36
+
32
37
  async function exists(target) {
33
38
  try {
34
39
  return (await fs.stat(target)).isFile();
@@ -45,6 +50,18 @@ async function retireLegacyLayout(host) {
45
50
  await removeEmptyLayout(host.legacyCommandsDir);
46
51
  }
47
52
 
53
+ async function retireLegacyGuardLogs(host, { dryRun = false } = {}) {
54
+ const logDir = path.join(path.dirname(host.settingsPath), 'logs');
55
+ const lines = [];
56
+ for (const name of LEGACY_GUARD_LOG_FILES) {
57
+ const target = path.join(logDir, name);
58
+ if (!await exists(target)) continue;
59
+ if (!dryRun) await fs.rm(target, { force: true });
60
+ lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${path.join('logs', name)}.`);
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+
48
65
  function displayFile(file) {
49
66
  const entry = fileEntry(file);
50
67
  return `${entry.root}/${entry.path}`;
@@ -64,12 +81,6 @@ function classifyOrphan(state) {
64
81
  return 'modified';
65
82
  }
66
83
 
67
- function recordHasHooks(record, targets) {
68
- return Boolean(record)
69
- && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
70
- hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
71
- }
72
-
73
84
  function appliedOutput(states) {
74
85
  const count = (status) => states.filter((state) => state.status === status).length;
75
86
  const parts = [
@@ -104,12 +115,14 @@ function dryRunOutput(states, hookStates, legacy, oldHooks) {
104
115
  // "the previous undefined hook registration" five times during the Plan_25 migration — the one
105
116
  // dry run where the operator most needed to see which registrations were about to be taken away.
106
117
  oldHooks.forEach(({ hook, definition }) => lines.push(
107
- `Would remove the previous ${hook.event} hook registration for matcher ${definition.matcher}.`,
118
+ `Would remove the previous ${hook.event} hook registration ${definition.name} for matcher ${definition.matcher}.`,
108
119
  ));
109
120
  hookStates.forEach(({ state, target }) => {
110
121
  const { definition, registration } = target;
111
- if (!state.present) {
112
- lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
122
+ if (state.present && !state.current) {
123
+ lines.push(`Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`);
124
+ } else if (!state.present) {
125
+ lines.push(`Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
113
126
  }
114
127
  });
115
128
  lines.push('Would write installation record with new fingerprints in both roots.');
@@ -210,7 +223,7 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
210
223
  + ' The home was updated from the copy that answered, not from that checkout'
211
224
  + ` (npm i -g ${currentPackage.name}@${elsewhere.version} changes which one that is).`
212
225
  : '';
213
- const targets = hookTargets(host, env, currentPackage.version);
226
+ const targets = hookTargets(host, env);
214
227
  const inspectedHooks = await Promise.all(targets.map(async (target) => ({
215
228
  target,
216
229
  state: await inspectHook(host.settingsPath, target.spec),
@@ -220,8 +233,9 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
220
233
  new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
221
234
  { path: rule.target, fingerprint: ruleState.fingerprint });
222
235
  const changed = states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
223
- if (!changed && inspectedHooks.every(({ state }) => state.present)
236
+ if (!changed && inspectedHooks.every(({ state }) => state.current)
224
237
  && recordHasHooks(record, targets) && recordCurrent) {
238
+ const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun });
225
239
  if (!dryRun) {
226
240
  await addRulesOwner(host);
227
241
  // An update that installed the new layout but stopped before retiring the old one used to
@@ -230,10 +244,20 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
230
244
  // it on this path costs nothing when there is nothing left to retire.
231
245
  await retireLegacyLayout(host);
232
246
  }
233
- return { exitCode: 0, output: `codex-bridge is up to date with ${source}${mismatch}` };
247
+ return {
248
+ exitCode: 0,
249
+ output: [`codex-bridge is up to date with ${source}${mismatch}`, legacyLogOutput]
250
+ .filter(Boolean).join('\n'),
251
+ };
234
252
  }
235
253
  if (dryRun) {
236
- return { exitCode: 0, output: `Would update codex-bridge with ${source}.${mismatch}\n${dryRunOutput(states, inspectedHooks, legacy, oldHooks)}` };
254
+ const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun: true });
255
+ return {
256
+ exitCode: 0,
257
+ output: [`Would update codex-bridge with ${source}.${mismatch}`,
258
+ dryRunOutput(states, inspectedHooks, legacy, oldHooks), legacyLogOutput]
259
+ .filter(Boolean).join('\n'),
260
+ };
237
261
  }
238
262
 
239
263
  for (const state of orphanStates) {
@@ -249,7 +273,12 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
249
273
  const installed = await install({ host, force: true, packageRoot, env });
250
274
  if (installed.exitCode !== 0) return installed;
251
275
  await retireLegacyLayout(host);
252
- return { exitCode: 0, output: `${appliedOutput(states)}\nSource: ${source}${mismatch}` };
276
+ const legacyLogOutput = await retireLegacyGuardLogs(host);
277
+ return {
278
+ exitCode: 0,
279
+ output: [`${appliedOutput(states)}\nSource: ${source}${mismatch}`, legacyLogOutput]
280
+ .filter(Boolean).join('\n'),
281
+ };
253
282
  }
254
283
 
255
284
  export async function update(options = {}) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.6",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 1501 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.8",
4
+ "description": "Claude Code-to-Codex delegation with 7 guards, 1667 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -23,8 +23,8 @@
23
23
  "homepage": "https://github.com/lyupro/codex-bridge#readme",
24
24
  "type": "module",
25
25
  "bin": {
26
- "codex-bridge": "./bin/codex-bridge.mjs",
27
- "codexb": "./bin/codex-bridge.mjs"
26
+ "codex-bridge": "bin/codex-bridge.mjs",
27
+ "codexb": "bin/codex-bridge.mjs"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=24.0.0"
@@ -21,6 +21,13 @@ import { spawnSync } from 'node:child_process';
21
21
  import { ISOLATED_ROOTS } from './isolated-roots.mjs';
22
22
  import { readmeText, suiteCountMismatch } from './suite-count.mjs';
23
23
 
24
+ // One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
25
+ // verification reported green for two files it never touched. Refuse before any root is created.
26
+ if (process.argv.length > 3) {
27
+ console.error('run-tests: takes ONE pattern; pass a glob such as "tests/cli/{doctor,install}.test.mjs"');
28
+ process.exit(2);
29
+ }
30
+
24
31
  const created = ISOLATED_ROOTS.map((name) => [
25
32
  name,
26
33
  fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
@@ -103,10 +103,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
103
103
  a different id of your own choosing; the order id is the orchestrator's.
104
104
 
105
105
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a
106
- report from memory. If an attaching call is killed by a time ceiling, repeat the identical command
107
- once with `--no-wait`. This only checks state; it is never a final answer. If stdout contains the
108
- ready reply, return it normally. If the call exits 4, return to the ordinary waiting call with the
109
- same order id and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang.
106
+ report from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
107
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
108
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang.
110
109
  Give the ordinary attaching call `timeout: 1800000` (30 minutes).
111
110
 
112
111
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -52,7 +52,8 @@ job is to report the run status honestly, including failure.
52
52
  operator's real check command contained `&&`, and a compound operator in an argument makes the
53
53
  host stop applying its permission rule, which is how a delegation dies on a refusal.
54
54
  - Every build task file must contain exactly one `advice:` line from the orchestrator: an absolute
55
- path to an existing run folder whose `meta.json` identifies `codex-advisor`, or exactly
55
+ path to an existing run folder whose `meta.json` identifies a `codex-advisor` advise pass with
56
+ status `OK`, or exactly
56
57
  `mechanical`, `revert`, `docs-only`, or `test-only`. Anything else is refused before the run
57
58
  starts. Pass the task file as supplied; the orchestrator owns this line.
58
59
 
@@ -111,10 +112,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
111
112
  remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
112
113
 
113
114
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
114
- from memory. If the attaching call is killed by a time ceiling, repeat the identical command once with
115
- `--no-wait`. This only checks state; it is never a final answer. If stdout contains the ready reply,
116
- return it normally. If the call exits 4, return to the ordinary waiting call with the same `--order-id`
117
- and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
115
+ from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
116
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
117
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
118
118
  attaching call `timeout: 1800000` (30 minutes).
119
119
 
120
120
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -96,9 +96,9 @@ the runner parses it and issues the refusal. If no such grant line is present, t
96
96
  present in the command at all.
97
97
 
98
98
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. If the attaching call is killed
99
- by a time ceiling, repeat the identical command once with `--no-wait`. This only checks state; it is
100
- never a final answer. If stdout contains the ready reply, return it normally. If the call exits 4,
101
- return to the ordinary waiting call with the same `--order-id` and without `--no-wait`. A real run takes
99
+ by a time ceiling, run the identical command again: it attaches to the same
100
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
101
+ order yields and refuses every other. A real run takes
102
102
  20-25 minutes, which is normal, not a hang. Give the ordinary attaching call `timeout: 1800000` (30
103
103
  minutes).
104
104
 
@@ -107,10 +107,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
107
107
  remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
108
108
 
109
109
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
110
- from memory. If the attaching call is killed by a time ceiling, repeat the identical command once with
111
- `--no-wait`. This only checks state; it is never a final answer. If stdout contains the ready reply,
112
- return it normally. If the call exits 4, return to the ordinary waiting call with the same `--order-id`
113
- and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
110
+ from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
111
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
112
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
114
113
  attaching call `timeout: 1800000` (30 minutes).
115
114
 
116
115
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /** Enforces each dispatcher order at the host tool boundary and returns only the runner's verdict. */
3
+ import fs from 'node:fs';
4
+ import { AGENTS } from '../lib/agents.mjs';
5
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
6
+ import { sameCommand } from '../lib/dispatcher-command.mjs';
7
+ import { parseJsonText } from '../lib/json-file.mjs';
8
+ import {
9
+ decidePreToolUse,
10
+ gateOrder,
11
+ HANDBACK_TOOL,
12
+ isFinalOutput,
13
+ runnerOutput,
14
+ } from '../lib/dispatcher-gate.mjs';
15
+ import {
16
+ pruneDispatcherStates,
17
+ readDispatcherState,
18
+ updateDispatcherState,
19
+ } from '../lib/dispatcher-state.mjs';
20
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
21
+ import { observeSessionHost } from '../lib/host-observations.mjs';
22
+
23
+ function emit(decision, toolInput) {
24
+ const hookSpecificOutput = {
25
+ hookEventName: 'PreToolUse',
26
+ permissionDecision: decision.kind,
27
+ };
28
+ if (decision.kind === 'deny') hookSpecificOutput.permissionDecisionReason = decision.reason;
29
+ if (decision.kind === 'allow') {
30
+ hookSpecificOutput.updatedInput = { ...toolInput, message: decision.message };
31
+ }
32
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput })}\n`);
33
+ }
34
+
35
+ function errorMessage(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+
39
+ async function main() {
40
+ let payload;
41
+ try {
42
+ payload = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
43
+ } catch {
44
+ return;
45
+ }
46
+
47
+ if (payload?.hook_event_name === 'PreToolUse'
48
+ && typeof payload.session_id === 'string' && payload.session_id.length > 0
49
+ && typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0) {
50
+ try {
51
+ await observeSessionHost({
52
+ stateDir: BRAND_STATE_DIR,
53
+ sessionId: payload.session_id,
54
+ transcriptPath: payload.transcript_path,
55
+ });
56
+ } catch {}
57
+ }
58
+
59
+ if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
60
+ || typeof payload.session_id !== 'string' || payload.session_id.length === 0
61
+ || typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
62
+
63
+ const ids = {
64
+ stateDir: BRAND_STATE_DIR,
65
+ sessionId: payload.session_id,
66
+ agentId: payload.agent_id,
67
+ };
68
+
69
+ if (payload.hook_event_name === 'PreToolUse') {
70
+ let decision;
71
+ let order;
72
+ try {
73
+ order = gateOrder(payload);
74
+ const stored = readDispatcherState(ids);
75
+ const state = stored?.corrupt ? {} : (stored || {});
76
+ decision = decidePreToolUse({ payload, order, state });
77
+ const toolUseId = typeof payload.tool_use_id === 'string' && payload.tool_use_id.length > 0
78
+ ? payload.tool_use_id
79
+ : null;
80
+ await updateDispatcherState(ids, (current) => {
81
+ const next = {
82
+ ...(current.corrupt ? {} : current),
83
+ ...(decision.stateUpdate || {}),
84
+ };
85
+ if (toolUseId) {
86
+ const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
87
+ next.seenToolUseIds = [...seenToolUseIds, toolUseId].slice(-200);
88
+ }
89
+ return next;
90
+ });
91
+ } catch (error) {
92
+ decision = {
93
+ kind: 'deny',
94
+ reason: `codex-bridge dispatcher gate: internal error: ${errorMessage(error)}`,
95
+ };
96
+ }
97
+
98
+ if (payload.tool_name === HANDBACK_TOOL) {
99
+ try {
100
+ await recordHandbackWitness({
101
+ stateDir: BRAND_STATE_DIR,
102
+ kind: 'seen',
103
+ hostVersion: await witnessHostVersion(payload),
104
+ });
105
+ } catch {}
106
+ try {
107
+ pruneDispatcherStates({ stateDir: BRAND_STATE_DIR });
108
+ } catch {}
109
+ }
110
+
111
+ if (decision.kind !== 'pass') emit(decision, payload.tool_input || {});
112
+ return;
113
+ }
114
+
115
+ if ((payload.hook_event_name === 'PostToolUse' || payload.hook_event_name === 'PostToolUseFailure')
116
+ && payload.tool_name === 'Bash') {
117
+ try {
118
+ const order = gateOrder(payload);
119
+ if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
120
+ const result = runnerOutput(payload);
121
+ if (!result) return;
122
+ await updateDispatcherState(ids, (current) => ({
123
+ ...(current.corrupt ? {} : current),
124
+ runnerOutput: result.output,
125
+ runnerFinal: isFinalOutput(result.output),
126
+ runnerExitCode: result.exitCode,
127
+ }));
128
+ } catch {}
129
+ }
130
+ }
131
+
132
+ main().catch(() => {});
@@ -1,15 +1,14 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
3
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
4
4
  import { readJsonFileSync } from '../lib/json-file.mjs';
5
5
 
6
- const LOG_DIR = path.join(os.homedir(), '.claude', 'logs');
7
6
  /**
8
- * The file keeps its reply-guard name although two guards now share it: renaming would abandon the
9
- * budgets already spent on hosts in the field, and a guard that forgets its refusals starts
10
- * refusing the same call again.
7
+ * Two guards share this budget (reply-guard and stop-guard). It lived in Claude Code's own
8
+ * `~/.claude/logs/` until Plan_62 D14 moved package state into the brand home; the move abandons
9
+ * budgets spent before it, which is harmless because they are keyed by agent ids that no longer run.
11
10
  */
12
- export const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
11
+ export const BLOCKED_FILE = path.join(BRAND_STATE_DIR, 'reply-guard-tries.json');
13
12
  export const FORM = 'form';
14
13
  export const STATE = 'state';
15
14
  export const MAX_FORM_BLOCKS = 3;
@@ -129,6 +129,7 @@ export function recentRuns(runsDir, options = {}) {
129
129
  }
130
130
  if (!status || typeof status !== 'object' || Array.isArray(status)) return null;
131
131
  if (options.agent && status.agent !== options.agent) continue;
132
+ if (options.orderId && String(status.order_id ?? '').trim() !== options.orderId) continue;
132
133
  const timestamp = Date.parse(status.finished_at || status.started_at || '');
133
134
  if (!Number.isFinite(timestamp) || timestamp > now || now - timestamp > maxAgeMs) continue;
134
135
  result.push({ dir, status, timestamp });
@@ -12,9 +12,9 @@
12
12
  * silently because a diagnostic guard must never break an unrelated tool call.
13
13
  */
14
14
  import fs from 'node:fs';
15
- import os from 'node:os';
16
15
  import path from 'node:path';
17
16
  import { AGENTS } from '../lib/agents.mjs';
17
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
18
18
  import { readJsonFileSync } from '../lib/json-file.mjs';
19
19
  import {
20
20
  diagnoseInput,
@@ -30,8 +30,7 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
30
30
  import { runsRoot } from '../lib/runner/runs-root.mjs';
31
31
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
32
32
 
33
- const HOME = os.homedir();
34
- const LOG_DIR = path.join(HOME, '.claude', 'logs');
33
+ const DIAGNOSTICS_DIR = path.join(BRAND_STATE_DIR, 'diagnostics');
35
34
  const GUARDED = new Set(Object.keys(AGENTS));
36
35
  /**
37
36
  * Both spellings of the subagent-launching tool, from the same list the installer builds its
@@ -60,8 +59,8 @@ try {
60
59
  }
61
60
 
62
61
  try {
63
- fs.mkdirSync(LOG_DIR, { recursive: true });
64
- fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
62
+ fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
63
+ fs.writeFileSync(path.join(DIAGNOSTICS_DIR, 'order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
65
64
  } catch {
66
65
  // Diagnostics are a convenience, never a reason to fail the turn.
67
66
  }
@@ -73,6 +72,22 @@ const toolInput = input.tool_input;
73
72
  if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
74
73
  if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
75
74
 
75
+ /**
76
+ * Plan_62 D7: with agent teams enabled, a registered dispatcher launched with `name` or `team_name`
77
+ * starts as a teammate — its `agent_type` becomes the teammate's name and it answers through
78
+ * SendMessage (live probe 2026-09-24, `docs/plans/Plan_62-probe/journal-run1.jsonl`), so the dispatcher
79
+ * gate never recognises it and every prohibition falls back to prompt text. Refused before it starts.
80
+ */
81
+ const teammateField = ['name', 'team_name']
82
+ .find((key) => typeof toolInput[key] === 'string' && toolInput[key].trim());
83
+ if (teammateField) {
84
+ deny(
85
+ `Order gate denied the Agent call because it passes \`${teammateField}\`: a dispatcher launched as a ` +
86
+ 'teammate escapes the dispatcher gate, whose agent_type becomes the teammate name. Call ' +
87
+ `${toolInput.subagent_type} with subagent_type only, without name or team_name.`,
88
+ );
89
+ }
90
+
76
91
  const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
77
92
  const unsafe = shellUnsafeInputs(toolInput.subagent_type, toolInput.prompt);
78
93
  if (missing.length || unsafe.length) {