@lyupro/codex-bridge 0.5.1 → 0.5.3

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 (89) hide show
  1. package/CHANGELOG.md +420 -252
  2. package/README.md +21 -4
  3. package/bin/codex-bridge.mjs +4 -0
  4. package/cli/doctor.mjs +399 -293
  5. package/cli/frontmatter.mjs +137 -0
  6. package/cli/hook.mjs +2 -2
  7. package/cli/install-record.mjs +2 -2
  8. package/cli/install.mjs +14 -2
  9. package/cli/manifest.mjs +23 -22
  10. package/cli/permissions.mjs +2 -2
  11. package/cli/projects.mjs +3 -3
  12. package/cli/prune-plan.mjs +2 -2
  13. package/cli/prune.mjs +1 -1
  14. package/cli/read.mjs +2 -2
  15. package/cli/rules-owners.mjs +2 -2
  16. package/cli/run-lookup.mjs +3 -3
  17. package/cli/runs-inventory.mjs +3 -3
  18. package/cli/settings-merge.mjs +56 -11
  19. package/cli/stop.mjs +5 -5
  20. package/cli/unlock.mjs +7 -7
  21. package/package.json +2 -2
  22. package/scripts/check-file-size.mjs +1 -1
  23. package/scripts/run-tests.mjs +30 -1
  24. package/scripts/suite-count.mjs +65 -0
  25. package/src/agents/codex-build.md +265 -231
  26. package/src/agents/codex-review.md +219 -184
  27. package/src/agents/codex-scout.md +232 -200
  28. package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
  29. package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
  30. package/src/home/hooks/order-gate.mjs +135 -0
  31. package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
  32. package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
  33. package/src/home/hooks/reply-parser.mjs +27 -0
  34. package/src/home/hooks/reply-verdicts.mjs +174 -0
  35. package/src/home/hooks/shell-write-intent.mjs +120 -0
  36. package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
  37. package/src/home/hooks/worktree-lock.mjs +109 -0
  38. package/src/home/hooks/worktree-witness.mjs +113 -0
  39. package/src/home/lib/dispatcher-order.mjs +36 -0
  40. package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +4 -1
  41. package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +11 -2
  42. package/src/home/lib/host-refusal.mjs +20 -0
  43. package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
  44. package/src/{meta → home/lib/meta}/paths.mjs +20 -1
  45. package/src/{meta → home/lib/meta}/reply.mjs +1 -1
  46. package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
  47. package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
  48. package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
  49. package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
  50. package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
  51. package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
  52. package/src/{runner → home/lib/runner}/args.mjs +86 -10
  53. package/src/{runner → home/lib/runner}/attach.mjs +58 -9
  54. package/src/{runner → home/lib/runner}/codex-cmd.mjs +398 -392
  55. package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
  56. package/src/home/lib/runner/order-owner.mjs +28 -0
  57. package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
  58. package/src/home/lib/runner/sandbox-flags.mjs +27 -0
  59. package/src/home/lib/runner/task-file.mjs +92 -0
  60. package/src/home/lib/runner/task-input.mjs +26 -0
  61. package/src/{runner → home/lib/runner}/worker.mjs +1 -1
  62. package/src/home/lib/shell-unsafe.mjs +26 -0
  63. package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
  64. package/src/hooks/order-gate.mjs +0 -73
  65. package/src/hooks/worktree-lock.mjs +0 -77
  66. /package/src/{run-config.json → home/config.json} +0 -0
  67. /package/src/{conventions.md → home/conventions.md} +0 -0
  68. /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
  69. /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
  70. /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
  71. /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
  72. /package/src/{meta → home/lib/meta}/events.mjs +0 -0
  73. /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
  74. /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
  75. /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
  76. /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
  77. /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
  78. /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
  79. /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
  80. /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
  81. /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
  82. /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
  83. /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
  84. /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
  85. /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
  86. /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
  87. /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
  88. /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
  89. /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Reads and normalizes the flat frontmatter shipped by this package.
3
+ *
4
+ * The 2026-08-10 agent-registration incident showed that placeholder text can turn a previously
5
+ * plain YAML value into invalid syntax. Keeping this boundary deliberately flat makes host
6
+ * readability testable without adding a general YAML parser or changing markdown bodies.
7
+ */
8
+
9
+ const PLAIN_START = /^(?:[-?:](?:[ \t]|$)|[,\[\]{}#&*!|>'"%@`])/;
10
+
11
+ function locateFrontmatter(content) {
12
+ const firstBreak = content.indexOf('\n');
13
+ if (firstBreak === -1 || content.slice(0, firstBreak).replace(/\r$/, '') !== '---') return null;
14
+
15
+ const blockStart = firstBreak + 1;
16
+ let lineStart = blockStart;
17
+ while (lineStart <= content.length) {
18
+ const nextBreak = content.indexOf('\n', lineStart);
19
+ const lineEnd = nextBreak === -1 ? content.length : nextBreak;
20
+ if (content.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
21
+ return { blockStart, blockEnd: lineStart };
22
+ }
23
+ if (nextBreak === -1) break;
24
+ lineStart = nextBreak + 1;
25
+ }
26
+ throw new Error('frontmatter closing delimiter is missing');
27
+ }
28
+
29
+ function blockLines(block) {
30
+ const lines = [];
31
+ let start = 0;
32
+ while (start < block.length) {
33
+ const nextBreak = block.indexOf('\n', start);
34
+ const end = nextBreak === -1 ? block.length : nextBreak;
35
+ const hasCarriageReturn = block[end - 1] === '\r';
36
+ lines.push({
37
+ text: block.slice(start, hasCarriageReturn ? end - 1 : end),
38
+ ending: nextBreak === -1 ? '' : hasCarriageReturn ? '\r\n' : '\n',
39
+ });
40
+ if (nextBreak === -1) break;
41
+ start = nextBreak + 1;
42
+ }
43
+ return lines;
44
+ }
45
+
46
+ function splitEntry(line) {
47
+ const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(line);
48
+ if (!match) throw new Error(`frontmatter line is not a flat key/value entry: ${line}`);
49
+ const remainder = match[2];
50
+ if (remainder && !/^[ \t]/.test(remainder)) {
51
+ throw new Error(`frontmatter value for ${match[1]} must follow YAML separator whitespace`);
52
+ }
53
+ return { key: match[1], rawValue: remainder ? remainder.slice(1) : '' };
54
+ }
55
+
56
+ function decodeDoubleQuoted(value) {
57
+ if (!value.endsWith('"')) return null;
58
+ let decoded = '';
59
+ for (let index = 1; index < value.length - 1; index += 1) {
60
+ const character = value[index];
61
+ if (character === '"') return null;
62
+ if (character !== '\\') {
63
+ decoded += character;
64
+ continue;
65
+ }
66
+ const escaped = value[index + 1];
67
+ if (escaped !== '\\' && escaped !== '"') return null;
68
+ decoded += escaped;
69
+ index += 1;
70
+ }
71
+ return decoded;
72
+ }
73
+
74
+ function decodeSingleQuoted(value) {
75
+ if (!value.endsWith("'")) return null;
76
+ const inner = value.slice(1, -1);
77
+ if (/(^|[^'])'(?:[^']|$)/.test(inner)) return null;
78
+ return inner.replaceAll("''", "'");
79
+ }
80
+
81
+ function quotedValue(value) {
82
+ if (value.startsWith('"')) return decodeDoubleQuoted(value);
83
+ if (value.startsWith("'")) return decodeSingleQuoted(value);
84
+ return undefined;
85
+ }
86
+
87
+ function plainScalarIsSafe(value) {
88
+ return value.length > 0
89
+ && value === value.trim()
90
+ && !PLAIN_START.test(value)
91
+ && !/:[ \t]/.test(value)
92
+ && !/[ \t]#/.test(value);
93
+ }
94
+
95
+ function parseValue(rawValue) {
96
+ const decoded = quotedValue(rawValue);
97
+ if (decoded !== undefined) {
98
+ if (decoded === null) throw new Error('frontmatter contains an invalid quoted value');
99
+ return decoded;
100
+ }
101
+ if (!plainScalarIsSafe(rawValue)) {
102
+ throw new Error(`frontmatter contains an invalid plain scalar: ${rawValue}`);
103
+ }
104
+ return rawValue;
105
+ }
106
+
107
+ function encodeValue(value) {
108
+ const decoded = quotedValue(value);
109
+ const intended = decoded === undefined || decoded === null ? value : decoded;
110
+ if (decoded === undefined && plainScalarIsSafe(value)) return value;
111
+ return `"${intended.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
112
+ }
113
+
114
+ export function parseFrontmatter(content) {
115
+ const bounds = locateFrontmatter(content);
116
+ if (!bounds) return null;
117
+ const values = {};
118
+ for (const { text } of blockLines(content.slice(bounds.blockStart, bounds.blockEnd))) {
119
+ const { key, rawValue } = splitEntry(text);
120
+ if (Object.hasOwn(values, key)) throw new Error(`frontmatter contains duplicate key: ${key}`);
121
+ values[key] = parseValue(rawValue);
122
+ }
123
+ return values;
124
+ }
125
+
126
+ export function normalizeFrontmatter(content) {
127
+ const bounds = locateFrontmatter(content);
128
+ if (!bounds) return content;
129
+ const keys = new Set();
130
+ const normalized = blockLines(content.slice(bounds.blockStart, bounds.blockEnd)).map(({ text, ending }) => {
131
+ const { key, rawValue } = splitEntry(text);
132
+ if (keys.has(key)) throw new Error(`frontmatter contains duplicate key: ${key}`);
133
+ keys.add(key);
134
+ return `${key}: ${encodeValue(rawValue)}${ending}`;
135
+ }).join('');
136
+ return `${content.slice(0, bounds.blockStart)}${normalized}${content.slice(bounds.blockEnd)}`;
137
+ }
package/cli/hook.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Dispatches a named host hook without changing the guard's stdin or process lifecycle. */
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
- import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
4
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
5
5
 
6
- const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'hooks');
6
+ const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'home', 'hooks');
7
7
  const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
8
8
 
9
9
  function validNames() {
@@ -1,8 +1,8 @@
1
1
  /** Reads, validates, and writes the installation record shared by installer commands. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { readJsonFile } from '../src/json-file.mjs';
5
- import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
4
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
5
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
6
6
 
7
7
  export const INSTALL_RECORD_NAME = '.installed.json';
8
8
  export const LEGACY_INSTALL_RECORD_NAME = '.codex-bridge-install.json';
package/cli/install.mjs CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  withSettingsRun,
26
26
  } from './settings-merge.mjs';
27
27
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
28
- import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
28
+ import { addPermissionRules } from './permissions.mjs';
29
+ import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
29
30
 
30
31
  const WARNING = '\u001b[33m';
31
32
  const RESET = '\u001b[0m';
@@ -158,6 +159,13 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
158
159
  }
159
160
  const hookResults = [];
160
161
  for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
162
+ // Uninstall has always removed these rules, and install never granted them — so removal took
163
+ // away what installation never gave. Without the rule the host refuses the package command,
164
+ // and the 2026-08-15 `cartoons-r136-scout-lock-scope` order shows what a dispatcher does next:
165
+ // it goes looking for a way around, down to the absolute path to run-codex.mjs that Plan_41
166
+ // removed. The rule follows the scope of the install, because host.settingsPath already is the
167
+ // scope — a --scope project install must not reach into the operator's global config.
168
+ const permissionResult = dryRun ? { added: 0 } : await addPermissionRules(host.settingsPath);
161
169
  const fingerprints = {};
162
170
  for (const item of plan) {
163
171
  fingerprints[item.root] ??= {};
@@ -188,7 +196,11 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
188
196
  });
189
197
  return {
190
198
  exitCode: 0,
191
- output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
199
+ output: retentionOutput(
200
+ configuredRetentionLine,
201
+ `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
202
+ + `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
203
+ ),
192
204
  };
193
205
  }
194
206
 
package/cli/manifest.mjs CHANGED
@@ -3,14 +3,15 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
6
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
7
7
  import {
8
8
  renderRequiredInputSummary,
9
9
  renderRequiredInputs,
10
- } from '../src/required-inputs.mjs';
11
- import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
12
- import { renderStopSummary } from '../src/stop-contract.mjs';
13
- import { readJsonFile } from '../src/json-file.mjs';
10
+ } from '../src/home/lib/required-inputs.mjs';
11
+ import { renderNoSelfExecution } from '../src/home/lib/no-self-execution.mjs';
12
+ import { renderStopSummary } from '../src/home/lib/stop-contract.mjs';
13
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
14
+ import { normalizeFrontmatter } from './frontmatter.mjs';
14
15
  import {
15
16
  INSTALL_RECORD_NAME,
16
17
  RULES_NAME,
@@ -49,18 +50,19 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
49
50
 
50
51
  /**
51
52
  * Files the operator owns once they exist. Seeded on first install so the defaults are
52
- * visible and editable, then never written, never compared, never removed: run-config.json
53
+ * visible and editable, then never written, never compared, never removed: config.json
53
54
  * is where a host states which model each mode runs on, conventions.md is the rule set it hands
54
55
  * every run, and an installer that overwrites either would erase host decisions on the next
55
56
  * update — the same way overwriting a .env would.
56
57
  */
57
- export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
58
+ export const SEEDED_SOURCES = Object.freeze(['src/home/config.json', 'src/home/conventions.md']);
58
59
 
59
60
  export const INSTALL_TABLE = Object.freeze([
60
61
  { source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
61
62
  { source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
62
- { source: 'src/hooks/**', root: 'brand', target: 'brandHooksDir', processing: 'copy' },
63
- { source: 'src/**', root: 'brand', target: 'brandRunnerDir', processing: 'copy' },
63
+ // The source is the host image. Keeping one source root and one target root makes any future
64
+ // remapping visible as a test failure instead of another clone-only import success.
65
+ { source: 'src/home/**', root: 'brand', target: 'brandRoot', processing: 'copy' },
64
66
  // The runner reads its own version out of the package.json beside it, so meta.json can say
65
67
  // which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
66
68
  // this line the installed layout has nothing one level above lib/, and every run on a real host
@@ -72,12 +74,13 @@ const posix = (value) => value.split(path.sep).join('/');
72
74
 
73
75
  export function replacePlaceholders(content, installationRoot) {
74
76
  const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
75
- return content
77
+ const replaced = content
76
78
  .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
77
79
  .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
78
80
  .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
79
81
  .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
80
82
  .replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
83
+ return normalizeFrontmatter(replaced);
81
84
  }
82
85
 
83
86
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
@@ -92,14 +95,13 @@ export function installedHookPath(host, definition) {
92
95
  /** Where each seeded file comes from and where it goes; contents are copied verbatim. */
93
96
  export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
94
97
  return SEEDED_SOURCES.map((source) => {
95
- const target = source.endsWith('run-config.json')
96
- ? host.brandConfigPath
97
- : host.brandConventionsPath;
98
+ const relativeToRoot = posix(path.relative('src/home', source));
99
+ const target = path.join(host.brandRoot, relativeToRoot);
98
100
  return {
99
101
  source: path.join(packageRoot, source),
100
102
  target,
101
103
  root: 'brand',
102
- relativeToRoot: posix(path.relative(host.brandRoot, target)),
104
+ relativeToRoot,
103
105
  processing: 'copy',
104
106
  };
105
107
  });
@@ -115,13 +117,17 @@ export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
115
117
 
116
118
  export async function fileFingerprint(absolutePath) {
117
119
  try {
118
- return createHash('sha256').update(await fs.readFile(absolutePath)).digest('hex');
120
+ return contentFingerprint(await fs.readFile(absolutePath));
119
121
  } catch (err) {
120
122
  if (err.code === 'ENOENT') return null;
121
123
  throw err;
122
124
  }
123
125
  }
124
126
 
127
+ export function contentFingerprint(content) {
128
+ return createHash('sha256').update(content).digest('hex');
129
+ }
130
+
125
131
  function targetBase(host, mapping) {
126
132
  const base = host[mapping.target];
127
133
  if (!base) throw new Error(`host has no ${mapping.target} install target`);
@@ -136,17 +142,13 @@ function targetRelative(packageRoot, source, mapping) {
136
142
  if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
137
143
  return path.basename(source);
138
144
  }
139
- const sourceRoot = mapping.source === 'src/hooks/**'
140
- ? path.join(packageRoot, 'src', 'hooks')
141
- : path.join(packageRoot, 'src');
142
- return path.relative(sourceRoot, source);
145
+ return path.relative(path.join(packageRoot, 'src', 'home'), source);
143
146
  }
144
147
 
145
148
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
146
149
  const files = [];
147
- const claimedSources = [];
148
150
  for (const mapping of INSTALL_TABLE) {
149
- for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
151
+ for await (const relative of fs.glob(mapping.source, { cwd: packageRoot })) {
150
152
  if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
151
153
  const source = path.join(packageRoot, relative);
152
154
  if (!(await fs.stat(source)).isFile()) continue;
@@ -165,7 +167,6 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
165
167
  : rootFor(host, mapping),
166
168
  });
167
169
  }
168
- claimedSources.push(mapping.source);
169
170
  }
170
171
  return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
171
172
  }
@@ -1,8 +1,8 @@
1
1
  /** Manages the optional shell permission rules for every codex-bridge command spelling. */
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { CLI_NAMES } from '../src/cli-names.mjs';
5
- import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
4
+ import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
5
+ import { SHELL_TOOLS } from '../src/home/lib/hook-definitions.mjs';
6
6
  import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
7
7
 
8
8
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
package/cli/projects.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Implements the projects command. */
2
2
  import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
3
  import { renderTable } from './table.mjs';
4
- import { allLiveRuns } from '../src/hooks/live-runs.mjs';
5
- import { runsRoot } from '../src/runner/runs-root.mjs';
6
- import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
4
+ import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
5
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
6
+ import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
7
7
 
8
8
  // The table renders whatever it is given; how a byte count or a timestamp reads is this command's
9
9
  // business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
@@ -3,8 +3,8 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { listProjectRuns, listProjects, recursiveSize } from './runs-inventory.mjs';
5
5
  import { parseOlderThan } from './prune-args.mjs';
6
- import { runsRoot } from '../src/runner/runs-root.mjs';
7
- import { runIsOlderThan, TRANSPORT_FILES } from '../src/retention.mjs';
6
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
7
+ import { runIsOlderThan, TRANSPORT_FILES } from '../src/home/lib/retention.mjs';
8
8
 
9
9
  export { runIsOlderThan, TRANSPORT_FILES };
10
10
 
package/cli/prune.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import readline from 'node:readline/promises';
5
- import { normalizePath } from '../src/hooks/live-runs.mjs';
5
+ import { normalizePath } from '../src/home/hooks/live-runs.mjs';
6
6
  import { parsePruneArgs } from './prune-args.mjs';
7
7
  import { prunePlan } from './prune-plan.mjs';
8
8
  import { recursiveSize } from './runs-inventory.mjs';
package/cli/read.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Renders a run's structured Codex transport as a readable event-by-event report. */
2
- import { readEvents } from '../src/meta/events.mjs';
3
- import { runsRoot } from '../src/runner/runs-root.mjs';
2
+ import { readEvents } from '../src/home/lib/meta/events.mjs';
3
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
4
4
  import { resolveRunFolder } from './run-lookup.mjs';
5
5
 
6
6
  const result = (exitCode, output) => ({ exitCode, output });
@@ -2,8 +2,8 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
- import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
6
- import { readJsonFile } from '../src/json-file.mjs';
5
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
6
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
7
7
 
8
8
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
9
9
  export const RULES_REGISTRY_VERSION = 1;
@@ -1,9 +1,9 @@
1
1
  /** Resolves a run argument and confirms that it names an existing run directory. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { git } from '../src/runner/git-state.mjs';
5
- import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
6
- import { runsRoot } from '../src/runner/runs-root.mjs';
4
+ import { git } from '../src/home/lib/runner/git-state.mjs';
5
+ import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
6
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
7
7
 
8
8
  function projectRoot(cwd) {
9
9
  const result = git(cwd, ['rev-parse', '--show-toplevel']);
@@ -1,9 +1,9 @@
1
1
  /** Reads run-store directories into inventory data. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { liveRuns } from '../src/hooks/live-runs.mjs';
5
- import { readJsonFileSync } from '../src/json-file.mjs';
6
- import { runsRoot } from '../src/runner/runs-root.mjs';
4
+ import { liveRuns } from '../src/home/hooks/live-runs.mjs';
5
+ import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
6
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
7
7
 
8
8
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
9
9
 
@@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs';
5
5
  import fs from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { randomUUID } from 'node:crypto';
8
- import { readJsonFileWithRaw } from '../src/json-file.mjs';
8
+ import { readJsonFileWithRaw } from '../src/home/lib/json-file.mjs';
9
9
 
10
10
  const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
11
11
  const settingsRuns = new AsyncLocalStorage();
@@ -134,29 +134,41 @@ const hasOwnCommand = (group, commands) => {
134
134
  };
135
135
 
136
136
  /**
137
- * Presence is decided by the command, not by the matcher it currently sits under.
137
+ * Presence is decided by the command, not by the matcher it currently sits under; writing then
138
+ * places that command under the matcher the package declares. These are two halves of one rule.
138
139
  *
139
140
  * The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
140
141
  * host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
141
142
  * will grow the same way. A host installed under the old matcher would then be invisible to the
142
143
  * new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
143
- * deleted file. The command is this package's own absolute path, so it identifies our entry
144
- * wherever the group ended up.
144
+ * deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
145
+ * worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
146
+ * is this package's own absolute path, so it identifies our entry wherever the group ended up,
147
+ * and merge can move it without creating a duplicate.
145
148
  */
146
149
  export async function inspectHook(settingsPath, specOrPath) {
147
150
  const state = await readSettings(settingsPath);
148
151
  const spec = normalizeSpec(specOrPath);
149
152
  const commands = [spec.command, ...spec.alternateCommands];
150
153
  let matchedCommand;
154
+ let matchedMatcher;
155
+ let requiresMove = false;
151
156
  for (const group of groups(state.settings, spec.event)) {
152
- const hook = groupHooks(group).find((entry) =>
153
- entry?.type === 'command' && commands.includes(entry.command));
154
- if (hook) {
155
- matchedCommand = hook.command;
156
- break;
157
+ for (const hook of groupHooks(group)) {
158
+ if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
159
+ matchedCommand ??= hook.command;
160
+ matchedMatcher ??= group?.matcher;
161
+ if (group?.matcher !== spec.matcher) requiresMove = true;
157
162
  }
158
163
  }
159
- return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
164
+ return {
165
+ ...state,
166
+ ...spec,
167
+ present: Boolean(matchedCommand),
168
+ matchedCommand,
169
+ matchedMatcher,
170
+ requiresMove,
171
+ };
160
172
  }
161
173
 
162
174
  function backupName(settingsPath) {
@@ -227,10 +239,43 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
227
239
  return withMutationRun(settingsPath, async () => {
228
240
  const spec = normalizeSpec(specOrPath);
229
241
  const state = inspected || await inspectHook(settingsPath, spec);
230
- if (state.present) return { changed: false, createdGroup: false };
242
+ if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
231
243
  const settings = structuredClone(state.settings);
232
244
  settings.hooks ??= {};
233
245
  if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
246
+ if (state.present) {
247
+ const eventGroups = settings.hooks[spec.event];
248
+ const commands = [spec.command, ...spec.alternateCommands];
249
+ let hookToMove;
250
+ let keptDeclaredHook = false;
251
+ for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
252
+ const group = eventGroups[index];
253
+ if (!Array.isArray(group?.hooks)) continue;
254
+ const hadOwnHook = hasOwnCommand(group, commands);
255
+ group.hooks = group.hooks.filter((hook) => {
256
+ if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
257
+ hookToMove ??= hook;
258
+ if (group.matcher === spec.matcher && !keptDeclaredHook) {
259
+ keptDeclaredHook = true;
260
+ return true;
261
+ }
262
+ return false;
263
+ });
264
+ if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
265
+ }
266
+ let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
267
+ const createdGroup = !group;
268
+ if (!keptDeclaredHook) {
269
+ if (!group) {
270
+ group = { matcher: spec.matcher, hooks: [] };
271
+ eventGroups.push(group);
272
+ }
273
+ if (!Array.isArray(group.hooks)) group.hooks = [];
274
+ group.hooks.push(hookToMove);
275
+ }
276
+ await atomicWrite(settingsPath, settings, state);
277
+ return { changed: true, createdGroup, moved: true };
278
+ }
234
279
  let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
235
280
  const createdGroup = !group;
236
281
  if (!group) {
package/cli/stop.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  /** Stops a live run and records the same abandoned FAIL artifacts as the normal unlock. */
2
2
  import path from 'node:path';
3
- import { stopCodex } from '../src/runner/codex-cmd.mjs';
4
- import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
3
+ import { stopCodex } from '../src/home/lib/runner/codex-cmd.mjs';
4
+ import { git, worktreeSnapshot } from '../src/home/lib/runner/git-state.mjs';
5
5
  import {
6
6
  IDENTITY_ALIVE,
7
7
  IDENTITY_FOREIGN,
8
8
  IDENTITY_UNVERIFIED,
9
9
  processAlive,
10
10
  processIdentity,
11
- } from '../src/process-identity.mjs';
12
- import { markAbandoned, readJson } from '../src/write-meta.mjs';
13
- import { runsRoot } from '../src/runner/runs-root.mjs';
11
+ } from '../src/home/lib/process-identity.mjs';
12
+ import { markAbandoned, readJson } from '../src/home/lib/write-meta.mjs';
13
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
14
14
  import { resolveRunFolder } from './run-lookup.mjs';
15
15
 
16
16
  const EXIT_POLL_MS = 25;
package/cli/unlock.mjs CHANGED
@@ -2,17 +2,17 @@
2
2
  /** Closes dead-pid running records without deleting any run artifacts. */
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/heartbeat.mjs';
6
- import { markAbandoned } from '../src/meta/run-state.mjs';
5
+ import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
6
+ import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
7
7
  import {
8
8
  IDENTITY_ALIVE,
9
9
  IDENTITY_UNVERIFIED,
10
10
  processIdentity,
11
- } from '../src/process-identity.mjs';
12
- import { git } from '../src/runner/git-state.mjs';
13
- import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
14
- import { runsRoot } from '../src/runner/runs-root.mjs';
15
- import { readJson } from '../src/write-meta.mjs';
11
+ } from '../src/home/lib/process-identity.mjs';
12
+ import { git } from '../src/home/lib/runner/git-state.mjs';
13
+ import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
14
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
15
+ import { readJson } from '../src/home/lib/write-meta.mjs';
16
16
  import { renderTable } from './table.mjs';
17
17
 
18
18
  const CLOSED_COLUMNS = [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.5.1",
4
- "description": "Claude Code-to-Codex delegation with 5 guards, 604 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.5.3",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 757 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -21,7 +21,7 @@
21
21
  import fs from 'node:fs/promises';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
- import { readJsonFile } from '../src/json-file.mjs';
24
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
25
25
 
26
26
  const HERE = path.dirname(fileURLToPath(import.meta.url));
27
27
  export const REPO_ROOT = path.resolve(HERE, '..');
@@ -16,21 +16,50 @@
16
16
  import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
19
20
  import { spawnSync } from 'node:child_process';
20
21
  import { ISOLATED_ROOTS } from './isolated-roots.mjs';
22
+ import { readmeText, suiteCountMismatch } from './suite-count.mjs';
21
23
 
22
24
  const created = ISOLATED_ROOTS.map((name) => [
23
25
  name,
24
26
  fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
25
27
  ]);
26
28
  const pattern = process.argv[2] || 'tests/**/*.test.mjs';
29
+ // Only a whole-suite run can judge the README's number; a single file legitimately reports two.
30
+ const wholeSuite = !process.argv[2];
31
+ const tapFile = wholeSuite
32
+ ? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
33
+ : null;
34
+ const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
35
+
36
+ // The spec reporter is named explicitly because the second reporter would otherwise replace it,
37
+ // and the operator would lose the live output this command exists to show.
38
+ const reporters = tapFile
39
+ ? ['--test-reporter=spec', '--test-reporter-destination=stdout',
40
+ '--test-reporter=tap', `--test-reporter-destination=${tapFile}`]
41
+ : [];
27
42
 
28
43
  try {
29
- const result = spawnSync(process.execPath, ['--test', pattern], {
44
+ const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
30
45
  stdio: 'inherit',
31
46
  env: { ...process.env, ...Object.fromEntries(created) },
32
47
  });
33
48
  process.exitCode = result.status ?? 1;
49
+ if (tapFile && process.exitCode === 0) {
50
+ let tap = null;
51
+ try {
52
+ tap = fs.readFileSync(tapFile, 'utf8');
53
+ } catch {
54
+ tap = null;
55
+ }
56
+ const mismatch = suiteCountMismatch(readmeText(path.join(repositoryRoot, 'README.md')), tap);
57
+ if (mismatch) {
58
+ process.stderr.write(`\nsuite-count: ${mismatch}\n`);
59
+ process.exitCode = 1;
60
+ }
61
+ }
34
62
  } finally {
35
63
  for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
64
+ if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
36
65
  }