@flowrail/init 0.0.17 → 0.1.0

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.
package/README.md CHANGED
@@ -21,6 +21,24 @@ What it writes:
21
21
 
22
22
  The installer finishes by probing `https://api.flowrail.ai/mcp` with your key to confirm end-to-end auth before exiting. A 401 gets an actionable message; transport failures get a different one.
23
23
 
24
+ ## OpenAI Codex (experimental)
25
+
26
+ ```bash
27
+ npx @flowrail/init@latest --agent codex
28
+ ```
29
+
30
+ This installs and self-tests the exact `@flowrail/hook` release it pins, then writes:
31
+ - `.codex/hooks.json`: four exact-pinned hooks.
32
+ - `.codex/config.toml`: the MCP server; the bearer comes from `FLOWRAIL_API_KEY`.
33
+ - `.agents/skills/` and a FlowRail section in `AGENTS.md`.
34
+ - `scripts/check-flowrail-codex.mjs`: the readiness check.
35
+
36
+ Nothing under `.claude/` changes.
37
+
38
+ Codex runs the hooks only after you **trust the project** and **approve the hooks in `/hooks`**.
39
+ Run `node scripts/check-flowrail-codex.mjs` until it reports `readiness: active`. Details:
40
+ <https://flowrail.ai/docs>.
41
+
24
42
  ## Links
25
43
 
26
44
  - Website: <https://flowrail.ai>
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * ADR-019 M0' — durable app-instance identity (Decision 1).
3
+ * ADR-021 M0' — durable app-instance identity (§1).
4
4
  *
5
5
  * ``init`` mints an ``app_instance_id`` (mint-if-absent) into
6
6
  * ``.flowrail/app.json`` and registers it with the server via the
@@ -112,7 +112,7 @@ function readAppIdentity(projectRoot) {
112
112
  }
113
113
  /** Write ``.flowrail/app.json``. Never clobbers a DIFFERENT id — the
114
114
  * mint is permanent for the life of the clone (re-mint only via
115
- * delete/re-clone, per ADR-019 Decision 1). */
115
+ * delete/re-clone, per ADR-021 §1). */
116
116
  function writeAppIdentity(projectRoot, identity) {
117
117
  const existing = readAppIdentity(projectRoot);
118
118
  if (existing !== null) {
@@ -3,7 +3,7 @@
3
3
  * Read / merge / write ``.claude/settings.json``.
4
4
  *
5
5
  * Wires PreToolUse hooks for Write/Edit/MultiEdit (pre-write
6
- * dispatcher) and Bash (pre-bash dispatcher), plus the ADR-019 Stop
6
+ * dispatcher) and Bash (pre-bash dispatcher), plus the ADR-021 Stop
7
7
  * (turn-batched whole-app review) and SessionStart (coverage catch-up)
8
8
  * hooks. All invoke ``flowrail-hook`` from the @flowrail/hook package
9
9
  * the tester is expected to have installed alongside this init.
@@ -27,7 +27,7 @@ const HOOK_BIN_NAME = 'flowrail-hook';
27
27
  const HOOK_BIN = `npx --yes -p @flowrail/hook ${HOOK_BIN_NAME}`;
28
28
  exports.PRE_WRITE_MATCHER = 'Write|Edit|MultiEdit';
29
29
  exports.PRE_BASH_MATCHER = 'Bash';
30
- // ADR-019 failure posture: the Stop/SessionStart hook timeout is sized
30
+ // ADR-021 §3 failure posture: the Stop/SessionStart hook timeout is sized
31
31
  // to batched whole-app reality and set EXPLICITLY in the generated
32
32
  // settings entry (seconds). It sits above the hook's own HTTP client
33
33
  // timeout so the client, not Claude Code, decides how a slow review
@@ -0,0 +1,388 @@
1
+ "use strict";
2
+ /**
3
+ * The generated Codex readiness doctor, `scripts/check-flowrail-codex.mjs`
4
+ * (ADR-050 D9). Self-contained ESM (node builtins only), regenerated by every
5
+ * `npx @flowrail/init --agent codex`.
6
+ *
7
+ * "Configured" is not "active": Codex only runs FlowRail's hooks after the
8
+ * user trusts the project AND approves each handler, and `codex exec` skips
9
+ * unapproved hooks silently. The doctor therefore reports one readiness
10
+ * state and never calls an install healthy unless Codex itself says the four
11
+ * FlowRail handlers are trusted and enabled:
12
+ *
13
+ * configured | needs-project-trust | needs-handler-approval | disabled |
14
+ * incompatible-version | active | unverified (codex not on PATH)
15
+ *
16
+ * Configuration errors (missing or malformed wiring, a hook release without
17
+ * the Codex adapter, a login shell that pollutes stdout) fail the check;
18
+ * pending approvals are warnings, so a fresh install never breaks a build
19
+ * before the user has had a chance to approve it in Codex.
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.CODEX_DOCTOR_FILENAME = void 0;
26
+ exports.renderCodexDoctorScript = renderCodexDoctorScript;
27
+ exports.writeCodexDoctorScript = writeCodexDoctorScript;
28
+ const fs_1 = __importDefault(require("fs"));
29
+ const path_1 = __importDefault(require("path"));
30
+ const codex_target_1 = require("./codex-target");
31
+ exports.CODEX_DOCTOR_FILENAME = 'scripts/check-flowrail-codex.mjs';
32
+ function renderCodexDoctorScript(version = codex_target_1.CODEX_HOOK_VERSION) {
33
+ return `#!/usr/bin/env node
34
+ // FlowRail readiness check for OpenAI Codex — generated by
35
+ // \`npx @flowrail/init --agent codex\`; re-running init regenerates it.
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import { spawn, spawnSync } from 'node:child_process';
39
+
40
+ const EXPECTED_VERSION = ${JSON.stringify(version)};
41
+ // The generated timeouts: a shorter one lets Codex kill the hook first, and
42
+ // a Codex hook timeout is a silent allow.
43
+ const TIMEOUTS = ${JSON.stringify(codex_target_1.CODEX_HOOK_TIMEOUTS)};
44
+ const EVENT = { 'pre-write': 'preToolUse', 'pre-bash': 'preToolUse', stop: 'stop', 'session-start': 'sessionStart' };
45
+ const TOML_MARKER = ${JSON.stringify(codex_target_1.TOML_BLOCK_BEGIN)};
46
+ const PLACEMENT = {
47
+ 'pre-write': ['PreToolUse', 'apply_patch'],
48
+ 'pre-bash': ['PreToolUse', 'Bash'],
49
+ stop: ['Stop', null],
50
+ 'session-start': ['SessionStart', null],
51
+ };
52
+ // Exactly the installer's grammar: the one endpoint assignment (bare or
53
+ // single-quoted), the exact pin, nothing else (ADR-050 D8).
54
+ const OURS = /^FLOWRAIL_MCP_URL=('(?:[^']|'\\\\'')*'|[A-Za-z0-9_@%+=:,.\\/-]+) npx --yes -p @flowrail\\/hook@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?) flowrail-hook agent codex (pre-write|pre-bash|stop|session-start)$/;
55
+ const REINIT = \`npx --yes @flowrail/init@latest --agent codex\`;
56
+ const LEGACY = /^(?:FLOWRAIL_[A-Z0-9_]*=\\S+\\s+)*npx\\s+--yes\\s+-p\\s+@flowrail\\/hook(?:@\\S+)?\\s+flowrail-hook\\s+(pre-write|pre-bash|stop|session-start)\\s*$/;
57
+
58
+ // The hook's own grammar: an http(s) endpoint and exactly the installer's
59
+ // text (canonical quoting) — anything else is not FlowRail wiring.
60
+ const shellQuote = (v) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(v) ? v : "'" + v.replace(/'/g, "'\\\\''") + "'");
61
+ function isInstallerCommand(command, m) {
62
+ const url = m[1].startsWith("'") ? m[1].slice(1, -1).replace(/'\\\\''/g, "'") : m[1];
63
+ let u;
64
+ try {
65
+ u = new URL(url);
66
+ } catch {
67
+ return false;
68
+ }
69
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
70
+ return command === \`FLOWRAIL_MCP_URL=\${shellQuote(url)} npx --yes -p @flowrail/hook@\${m[2]} flowrail-hook agent codex \${m[3]}\`;
71
+ }
72
+
73
+ let failures = 0;
74
+ const ok = (m) => console.log(\`✓ \${m}\`);
75
+ const warn = (m) => console.warn(\`⚠ \${m}\`);
76
+ const fail = (m) => { failures++; console.error(\`✗ \${m}\`); };
77
+ const root = process.cwd();
78
+ let readiness = 'configured';
79
+ let incompatible = false;
80
+
81
+ // 1. .codex/hooks.json — the four FlowRail handlers, where Codex expects them.
82
+ const handlers = {};
83
+ let pinned = new Set();
84
+ try {
85
+ const config = JSON.parse(fs.readFileSync(path.join(root, '.codex/hooks.json'), 'utf8'));
86
+ for (const [event, groups] of Object.entries(config.hooks ?? {})) {
87
+ if (!Array.isArray(groups)) continue;
88
+ groups.forEach((group) => {
89
+ (group?.hooks ?? []).forEach((h) => {
90
+ const m = h?.type === 'command' && typeof h?.command === 'string' ? OURS.exec(h.command) : null;
91
+ if (!m || !isInstallerCommand(h.command, m)) return;
92
+ const [wantEvent, wantMatcher] = PLACEMENT[m[3]];
93
+ if (event !== wantEvent) return;
94
+ // PreToolUse under its exact matcher; Stop and SessionStart under none.
95
+ if (wantMatcher === null ? group.matcher !== undefined && group.matcher !== null : group.matcher !== wantMatcher) return;
96
+ (handlers[m[3]] ??= []).push(h.command);
97
+ pinned.add(m[2]);
98
+ if (h.async === true) {
99
+ fail(\`.codex/hooks.json: the FlowRail \${m[3]} hook is asynchronous (async: true) — Codex would not wait for it, so it could not block anything — re-run \${REINIT}\`);
100
+ }
101
+ if (typeof h.timeout !== 'number' || h.timeout < TIMEOUTS[m[3]]) {
102
+ fail(\`.codex/hooks.json: the FlowRail \${m[3]} hook's timeout is \${h.timeout ?? 'unset (600s default)'}; it must be at least \${TIMEOUTS[m[3]]}s — re-run \${REINIT}\`);
103
+ }
104
+ });
105
+ });
106
+ }
107
+ for (const phase of Object.keys(PLACEMENT)) {
108
+ const found = handlers[phase] ?? [];
109
+ if (found.length === 1) continue;
110
+ fail(\`.codex/hooks.json: expected one FlowRail \${phase} hook, found \${found.length} — re-run \${REINIT}\`);
111
+ }
112
+ if (Object.keys(PLACEMENT).every((p) => (handlers[p] ?? []).length === 1)) ok('.codex/hooks.json wires all four FlowRail hooks');
113
+ if (pinned.size > 1) fail(\`.codex/hooks.json pins more than one @flowrail/hook version (\${[...pinned].join(', ')}) — re-run \${REINIT}\`);
114
+ else if (pinned.size === 1 && !pinned.has(EXPECTED_VERSION)) warn(\`hooks pin @flowrail/hook@\${[...pinned][0]}; this check expects \${EXPECTED_VERSION}\`);
115
+ } catch (err) {
116
+ fail(\`.codex/hooks.json is missing or not valid JSON (\${err.message}) — re-run \${REINIT}\`);
117
+ }
118
+
119
+ // 2. .codex/config.toml — FlowRail's MCP server block.
120
+ try {
121
+ const toml = fs.readFileSync(path.join(root, '.codex/config.toml'), 'utf8');
122
+ if (toml.includes(TOML_MARKER) && toml.includes('[mcp_servers.flowrail]') && toml.includes('bearer_token_env_var = "FLOWRAIL_API_KEY"')) {
123
+ ok('.codex/config.toml defines the FlowRail MCP server');
124
+ } else {
125
+ fail(\`.codex/config.toml has no FlowRail MCP block — re-run \${REINIT}\`);
126
+ }
127
+ } catch {
128
+ fail(\`.codex/config.toml is missing — re-run \${REINIT}\`);
129
+ }
130
+
131
+ // 3. The credential the hooks and the MCP client read.
132
+ if ((process.env.FLOWRAIL_API_KEY ?? '').trim()) ok('FLOWRAIL_API_KEY is set');
133
+ else warn('FLOWRAIL_API_KEY is not set in this shell — Codex must be started from a shell that has it');
134
+
135
+ // 4. Codex runs hooks through a login shell ($SHELL -lc) from the project.
136
+ // Anything that shell prints corrupts FlowRail's JSON decisions (Codex
137
+ // then IGNORES them — fails open), and a profile that changes PATH can
138
+ // hide npx entirely. So everything below runs exactly that way.
139
+ const shell = process.env.SHELL || '/bin/sh';
140
+ const firstLine = (t) => (t ?? '').trim().split('\\n')[0].slice(0, 160);
141
+ const viaShell = (command, extra = {}) =>
142
+ spawnSync(shell, ['-lc', command], { cwd: root, encoding: 'utf8', timeout: 60000, ...extra });
143
+ const probe = viaShell('true', { timeout: 10000 });
144
+ let shellClean = false;
145
+ if (probe.error) fail(\`could not start \${shell} -lc (\${probe.error.message}) — Codex could not run any hook\`);
146
+ else if ((probe.stdout ?? '') !== '') fail(\`\${shell} -lc prints output on startup ("\${firstLine(probe.stdout)}") — Codex would ignore FlowRail's decisions. Move that output to stderr or an interactive-only block.\`);
147
+ else { shellClean = true; ok(\`\${shell} -lc starts cleanly\`); }
148
+
149
+ // 5. The exact pinned hook release, run the way Codex runs it, must
150
+ // implement the Codex adapter...
151
+ let testedVersions = [];
152
+ const version = pinned.size === 1 ? [...pinned][0] : EXPECTED_VERSION;
153
+ if (shellClean) {
154
+ const caps = viaShell(\`npx --yes -p @flowrail/hook@\${version} flowrail-hook capabilities\`);
155
+ if (caps.error || caps.status !== 0) {
156
+ fail(\`@flowrail/hook@\${version} does not run through \${shell} -lc (\${caps.error?.message ?? firstLine(caps.stderr) ?? 'exit ' + caps.status}) — Codex runs hooks this way, so FlowRail would not run. Check that npx is on the PATH your login shell sets.\`);
157
+ } else {
158
+ try {
159
+ const c = JSON.parse(caps.stdout);
160
+ if (c.agents?.codex && c.adapter_protocol >= 1) {
161
+ ok(\`@flowrail/hook@\${version} implements the Codex adapter (\${c.agents.codex.state})\`);
162
+ testedVersions = Array.isArray(c.agents.codex.tested_versions) ? c.agents.codex.tested_versions : [];
163
+ } else { incompatible = true; fail(\`@flowrail/hook@\${version} does not implement the Codex adapter — re-run \${REINIT}\`); }
164
+ } catch {
165
+ fail(\`@flowrail/hook@\${version} capabilities printed something that is not JSON through \${shell} -lc — Codex would ignore FlowRail's decisions\`);
166
+ }
167
+ }
168
+
169
+ // ...and the generated pre-write command itself must block a patch that
170
+ // adds a test secret. Its endpoint is swapped for an unroutable one and
171
+ // the key for a dummy, so nothing reaches the FlowRail server.
172
+ const preWrite = (handlers['pre-write'] ?? [])[0];
173
+ if (preWrite && !incompatible) {
174
+ const probeCmd = preWrite.replace(/^FLOWRAIL_MCP_URL=('(?:[^']|'\\\\'')*'|\\S+)/, 'FLOWRAIL_MCP_URL=http://127.0.0.1:1');
175
+ const testSecret = 'AKIA' + 'IOSFODNN7' + 'EXAMPLE';
176
+ const payload = JSON.stringify({
177
+ session_id: '',
178
+ hook_event_name: 'PreToolUse',
179
+ cwd: root,
180
+ tool_name: 'apply_patch',
181
+ tool_input: { command: \`*** Begin Patch\\n*** Add File: flowrail-doctor-probe.ts\\n+const awsKey = "\${testSecret}";\\n*** End Patch\\n\` },
182
+ });
183
+ const r = viaShell(probeCmd, { input: payload, env: { ...process.env, FLOWRAIL_API_KEY: 'flowrail-doctor-probe' } });
184
+ if (r.status === 2 && /secret pattern detected/i.test(r.stderr ?? '')) ok('the generated pre-write hook blocks a test secret, run the way Codex runs it');
185
+ else fail(\`the generated pre-write hook did not block a test secret when run through \${shell} -lc (exit \${r.status}\${r.error ? ', ' + r.error.message : ''}) — FlowRail would not protect Codex writes\`);
186
+
187
+ // Its STRUCTURED deny (JSON on stdout, exit 0 — what a server-side
188
+ // verdict uses) must come back readable too: a patch for a file that
189
+ // does not exist is refused locally.
190
+ const missing = JSON.stringify({
191
+ session_id: '',
192
+ hook_event_name: 'PreToolUse',
193
+ cwd: root,
194
+ tool_name: 'apply_patch',
195
+ tool_input: { command: \`*** Begin Patch\\n*** Update File: flowrail-doctor-probe-missing.ts\\n@@\\n-a\\n+b\\n*** End Patch\\n\` },
196
+ });
197
+ const sd = viaShell(probeCmd, { input: missing, env: { ...process.env, FLOWRAIL_API_KEY: 'flowrail-doctor-probe' } });
198
+ let structured = false;
199
+ try {
200
+ structured = JSON.parse(sd.stdout).hookSpecificOutput?.permissionDecision === 'deny';
201
+ } catch {
202
+ structured = false;
203
+ }
204
+ if (sd.status === 0 && structured) ok('the generated pre-write hook returns a structured deny Codex can read, run the way Codex runs it');
205
+ else fail(\`the generated pre-write hook did not return a readable structured deny through \${shell} -lc (exit \${sd.status}) — Codex would ignore FlowRail's decisions\`);
206
+ }
207
+
208
+ // ...and a STRUCTURED deny (JSON on stdout — what shell noise corrupts)
209
+ // must come back readable: the pre-bash hook refusing a shell-run patch.
210
+ const preBash = (handlers['pre-bash'] ?? [])[0];
211
+ if (preBash && !incompatible) {
212
+ const probeCmd = preBash.replace(/^FLOWRAIL_MCP_URL=('(?:[^']|'\\\\'')*'|\\S+)/, 'FLOWRAIL_MCP_URL=http://127.0.0.1:1');
213
+ const payload = JSON.stringify({
214
+ session_id: '',
215
+ hook_event_name: 'PreToolUse',
216
+ cwd: root,
217
+ tool_name: 'Bash',
218
+ tool_input: { command: "apply_patch <<'EOF'\\n*** Begin Patch\\n*** End Patch\\nEOF" },
219
+ });
220
+ const r = viaShell(probeCmd, { input: payload, env: { ...process.env, FLOWRAIL_API_KEY: 'flowrail-doctor-probe' } });
221
+ let denied = false;
222
+ try {
223
+ denied = JSON.parse(r.stdout).hookSpecificOutput?.permissionDecision === 'deny';
224
+ } catch {
225
+ denied = false;
226
+ }
227
+ if (r.status === 0 && denied) ok('the generated pre-bash hook returns a structured deny Codex can read, run the way Codex runs it');
228
+ else fail(\`the generated pre-bash hook did not return a readable structured deny through \${shell} -lc (exit \${r.status}) — Codex would ignore FlowRail's decisions\`);
229
+ }
230
+ }
231
+
232
+ // 5b. Codex changes its hook payloads between releases: say so when the
233
+ // installed Codex is not one this hook release was tested with.
234
+ if (testedVersions.length > 0) {
235
+ const cv = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 10000 });
236
+ const installed = /(\\d+\\.\\d+\\.\\d+)/.exec(cv.stdout ?? '')?.[1];
237
+ if (installed && !testedVersions.includes(installed)) {
238
+ warn(\`Codex \${installed} is not a version this hook was tested with (\${testedVersions.join(', ')}) — FlowRail may miss writes if Codex changed its hook payloads\`);
239
+ }
240
+ }
241
+
242
+ // 6. Does Codex actually load, trust and enable the handlers? (model-free)
243
+ async function codexHooks() {
244
+ return await new Promise((resolve) => {
245
+ let child;
246
+ try {
247
+ child = spawn('codex', ['app-server'], { cwd: root, stdio: ['pipe', 'pipe', 'ignore'] });
248
+ } catch {
249
+ return resolve({ absent: true });
250
+ }
251
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve({ timeout: true }); }, 10000);
252
+ child.on('error', () => { clearTimeout(timer); resolve({ absent: true }); });
253
+ let buf = '';
254
+ child.stdout.on('data', (d) => {
255
+ buf += d.toString('utf8');
256
+ let i;
257
+ while ((i = buf.indexOf('\\n')) >= 0) {
258
+ const line = buf.slice(0, i); buf = buf.slice(i + 1);
259
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
260
+ if (msg.id === 1) {
261
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }) + '\\n');
262
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'hooks/list', params: { cwds: [root] } }) + '\\n');
263
+ } else if (msg.id === 2) {
264
+ clearTimeout(timer);
265
+ child.stdin.end(); child.kill();
266
+ resolve({ result: msg.result, error: msg.error });
267
+ }
268
+ }
269
+ });
270
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'flowrail-doctor', version: EXPECTED_VERSION } } }) + '\\n');
271
+ });
272
+ }
273
+
274
+ const listed = await codexHooks();
275
+ if (listed.absent) {
276
+ readiness = 'unverified';
277
+ warn('codex is not on PATH — cannot confirm Codex loads and trusts the FlowRail hooks');
278
+ } else if (listed.timeout || listed.error) {
279
+ readiness = 'unverified';
280
+ warn('codex app-server did not answer hooks/list — cannot confirm readiness');
281
+ } else {
282
+ const lists = listed.result?.data ?? [];
283
+ // Codex's own load problems (e.g. "failed to parse hooks config") are
284
+ // configuration errors, not pending approvals.
285
+ const describe = (w) => (typeof w === 'string' ? w : JSON.stringify(w));
286
+ for (const e of lists.flatMap((d) => d.errors ?? [])) {
287
+ fail(\`Codex reports an error loading hooks: \${firstLine(describe(e))} — fix the file it names, then re-run this check\`);
288
+ }
289
+ for (const w of lists.flatMap((d) => d.warnings ?? [])) {
290
+ const text = describe(w);
291
+ if (!/hook/i.test(text)) continue;
292
+ // A load failure drops the file (FlowRail is off); anything else — e.g.
293
+ // "prefer one representation" when hooks live in both config.toml and
294
+ // hooks.json — is advice and must not break the build.
295
+ // Codex 0.156 words load failures "failed to parse/read hooks config
296
+ // <path>: …"; match the message, never the path (which may say "error").
297
+ if (/^\\s*failed to /i.test(text)) {
298
+ fail(\`Codex reports a problem loading hooks: \${firstLine(text)} — fix the file it names, then re-run this check\`);
299
+ } else {
300
+ warn(\`Codex: \${firstLine(text)}\`);
301
+ }
302
+ }
303
+ // What Codex will actually run: FlowRail command handlers from this
304
+ // project's config, matched on event, matcher and exact command text.
305
+ const project = {};
306
+ let elsewhere = 0;
307
+ for (const h of lists.flatMap((d) => d.hooks ?? [])) {
308
+ const m = h.handlerType === 'command' && typeof h.command === 'string' ? OURS.exec(h.command) : null;
309
+ if (!m || !isInstallerCommand(h.command, m)) continue;
310
+ if (h.source !== 'project') {
311
+ elsewhere++;
312
+ continue;
313
+ }
314
+ const phase = m[3];
315
+ const wantMatcher = PLACEMENT[phase][1];
316
+ const placed = h.eventName === EVENT[phase] && (wantMatcher === null ? h.matcher === undefined || h.matcher === null : h.matcher === wantMatcher);
317
+ if (placed) (project[phase] ??= []).push({ ...h, pin: m[2] });
318
+ }
319
+ // A Claude Code FlowRail hook loaded by Codex (e.g. via import) receives
320
+ // Codex payloads and refuses every patch.
321
+ const legacyLoaded = lists
322
+ .flatMap((d) => d.hooks ?? [])
323
+ .filter((h) => h.handlerType === 'command' && typeof h.command === 'string' && LEGACY.test(h.command));
324
+ if (legacyLoaded.length > 0) {
325
+ fail(\`Codex loads \${legacyLoaded.length} Claude Code FlowRail hook(s) (\${legacyLoaded.map((h) => h.sourcePath || h.source).join(', ')}) — they would block every apply_patch; remove them\`);
326
+ }
327
+ if (elsewhere > 0) warn(\`\${elsewhere} FlowRail hook(s) are configured outside this project (user or managed config); Codex runs those as well\`);
328
+ const phases = Object.keys(PLACEMENT);
329
+ const loaded = phases.flatMap((p) => project[p] ?? []);
330
+ if (loaded.length === 0) {
331
+ readiness = 'needs-project-trust';
332
+ warn('Codex does not load this project\\'s FlowRail hooks yet: open the repo in Codex and trust the project');
333
+ } else {
334
+ let localReal = path.join(root, '.codex/hooks.json');
335
+ try {
336
+ localReal = fs.realpathSync.native(localReal);
337
+ } catch {}
338
+ const sources = [...new Set(loaded.map((h) => h.sourcePath).filter(Boolean))];
339
+ if (sources.some((src) => src !== localReal)) {
340
+ warn(\`Codex loads this project's hooks from \${sources.join(', ')} (a linked worktree loads the main checkout's), not this checkout's file — readiness is for what Codex loads\`);
341
+ }
342
+ let wrong = false;
343
+ for (const p of phases) {
344
+ const entries = project[p] ?? [];
345
+ if (entries.length !== 1) {
346
+ wrong = true;
347
+ fail(\`Codex loads \${entries.length} FlowRail \${p} hooks from this project (expected exactly 1, under the right event and matcher) — re-run \${REINIT}\`);
348
+ continue;
349
+ }
350
+ const h = entries[0];
351
+ if (h.pin !== version) {
352
+ wrong = true;
353
+ fail(\`the \${p} hook Codex loads runs @flowrail/hook@\${h.pin}, not the \${version} this check verified — re-run \${REINIT}\`);
354
+ }
355
+ if (h.async === true) {
356
+ wrong = true;
357
+ fail(\`the \${p} hook Codex loads is asynchronous — Codex runs it without waiting, so it cannot block anything — re-run \${REINIT}\`);
358
+ }
359
+ if (typeof h.timeoutSec === 'number' && h.timeoutSec < TIMEOUTS[p]) {
360
+ wrong = true;
361
+ fail(\`the \${p} hook Codex loads times out after \${h.timeoutSec}s (needs at least \${TIMEOUTS[p]}s; a Codex timeout lets the action through) — re-run \${REINIT}\`);
362
+ }
363
+ }
364
+ if (wrong) readiness = 'configured';
365
+ else if (loaded.some((h) => h.enabled === false)) {
366
+ readiness = 'disabled';
367
+ warn('a FlowRail hook is disabled in Codex: re-enable it with /hooks');
368
+ } else if (loaded.some((h) => h.trustStatus !== 'trusted')) {
369
+ readiness = 'needs-handler-approval';
370
+ warn(\`Codex has not approved every FlowRail hook (\${loaded.filter((h) => h.trustStatus === 'trusted').length}/4 trusted): run /hooks in Codex and approve them\`);
371
+ } else {
372
+ readiness = 'active';
373
+ ok('Codex loads, trusts and enables all four FlowRail hooks, as installed');
374
+ }
375
+ }
376
+ }
377
+ if (incompatible) readiness = 'incompatible-version';
378
+ if (failures > 0 && readiness === 'active') readiness = 'configured';
379
+
380
+ console.log(\`FlowRail for Codex readiness: \${readiness}\`);
381
+ process.exit(failures > 0 ? 1 : 0);
382
+ `;
383
+ }
384
+ function writeCodexDoctorScript(projectRoot, version = codex_target_1.CODEX_HOOK_VERSION) {
385
+ const target = path_1.default.join(projectRoot, exports.CODEX_DOCTOR_FILENAME);
386
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
387
+ fs_1.default.writeFileSync(target, renderCodexDoctorScript(version), 'utf8');
388
+ }