@nodii/dev 0.6.25 → 0.6.27

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
@@ -351,19 +351,40 @@ nodii-dev docs upload PATH... --class ruling|conformance|editorial
351
351
  nodii-dev docs ratify --domain KEY [--domain KEY ...] --decision DECISION.json [--wait]
352
352
  nodii-dev docs parked [CODE] [--status pending|acknowledged]
353
353
 
354
- nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run]
355
- nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run]
354
+ nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
355
+ nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
356
356
  nodii-dev harness check [--json] # 0 current, 1 differs from Dev, 2 no harness
357
357
  nodii-dev harness update --check # the same check
358
358
  ```
359
359
 
360
- `harness install` and `harness update` merge the two safety hooks into
361
- `.claude/settings.json`; every installed hook invokes only `nodii-dev`. The
360
+ `harness install` and `harness update` merge the safety hooks into both
361
+ `.claude/settings.json` and `.codex/hooks.json`; every installed hook invokes
362
+ only `nodii-dev`. The
362
363
  pre-hook blocks edits outside a lane's optional `ownedPaths`, generated UI/API
363
364
  files, unsafe Git operations, stale or missing review receipts, lane identity
364
365
  changes, production-secret/test-auth bypasses, cleanup of Docker resources the
365
366
  run did not create, and deploy/apply commands without an exact grant. Allowed
366
- calls produce no output.
367
+ calls produce no output. Codex receives runtime-native `Bash` and `apply_patch`
368
+ matchers, JSON denials, and `additionalContext` nudges. A compact session start
369
+ prints the current lane Freeze again.
370
+
371
+ Hard blocks apply only after Dev proves the current agent, branch and worktree
372
+ belong to an active lane. Interactive or otherwise unproven sessions receive
373
+ nudges instead. Push is blocked only when that proven lane also exposes the
374
+ review-receipt flow; until then the hook says it cannot verify the review and
375
+ allows the command.
376
+
377
+ Codex trusts system-managed hooks without a per-hash prompt. `--system` writes
378
+ the repository harness normally and refreshes the user-owned dispatcher under
379
+ `~/.config/nodii-dev/codex-managed-hooks`, then only prints the one-time
380
+ `sudo install` commands an operator can review and run to place its stable
381
+ policy in `/etc/codex/requirements.toml`; nodii-dev never runs sudo itself.
382
+ That system layer covers both `CODEX_HOME=~/.codex` and
383
+ `CODEX_HOME=~/.codex-work`; later `--system` updates need no sudo. The
384
+ dispatcher records the absolute executable path so hook processes never
385
+ depend on `PATH`, and refreshes it on every update. Outside a lane, or when
386
+ that executable is missing or too old, it fails open without diagnostics;
387
+ `doctor` reports that fail-open state for each Codex profile.
367
388
 
368
389
  Until Dev exposes a lane's owned paths (AGENT-HARNESS-10), an edit with no
369
390
  ownership field emits a nudge instead of claiming it is safe or blocking an
@@ -603,11 +624,14 @@ OAuth login.
603
624
  `harness install` writes the repository's agent harness as Dev generates it
604
625
  from the ratified methodology: the `CLAUDE.md` and `AGENTS.md` pointer
605
626
  blocks, one pointer skill per methodology document, the Claude Code hooks
606
- merged into `.claude/settings.json`, and Dev's `.nodii/harness.json`. Every
627
+ merged into `.claude/settings.json`, their Codex equivalents merged into
628
+ `.codex/hooks.json`, and Dev's `.nodii/harness.json`. Every
607
629
  generated file carries the methodology version it came from. Nothing about
608
630
  the rules is authored locally. Run it in the service's own repository: it
609
- installs one service at a time, and there is no fleet mode. `--include-drafts`
610
- uses draft methodology while none is ratified.
631
+ installs one service at a time, and there is no fleet mode. While no
632
+ methodology is ratified, install and update automatically retry with the
633
+ latest draft versions and print the exact draft methodology and source
634
+ versions they used. `--include-drafts` requests that behavior explicitly.
611
635
 
612
636
  The CLI records what it wrote in `.nodii/harness-state.json` and never touches
613
637
  anything else. An existing file it did not write is refused, unless the
@@ -655,6 +679,8 @@ exits 0 only when every check is `OK`. `--json` returns the same checks as
655
679
  | `lane_limit` | The agent's active lanes against its lane limit. A full limit is `OK` when a lane on this branch holds the slot. |
656
680
  | `figma_file` | Only with `--figma-file KEY` (a key or figma.com link), or `NODII_DEV_FIGMA_FILE`: whether Dev has the brief's Figma file registered. `MISSING` names the `figma file add` command, since `figma lock` would fail with `DESIGN_FILE_NOT_FOUND`. |
657
681
  | `figma_scopes` | With `figma_file`, when the file is registered: the change set this agent holds in the file and its lock scopes (or the whole file), or that it holds none (DEC-0032). |
682
+ | `hooks_claude` | Whether Claude hooks are installed, trusted by that runtime, and have a private firing receipt. |
683
+ | `hooks_codex_personal`, `hooks_codex_work` | For `~/.codex` and `~/.codex-work`, whether Codex's own hook registry reports the hooks installed and trusted (including managed status), whether the managed absolute CLI is present and new enough, and whether that profile has fired one. |
658
684
 
659
685
  Doctor may refresh the access token, as any command does. The permission table
660
686
  is kept by hand from Dev's route policy until Dev's OpenAPI names each
@@ -671,9 +697,9 @@ serves for that step (see Rule delivery). Server refusals,
671
697
  including operator-only actions, workflow blocks, lane limits and Built items
672
698
  that are not on dev, retain Dev's error code/message and exit non-zero.
673
699
 
674
- `setup` installs the Claude Code workflow harness in the current repository.
700
+ `setup` installs the Claude Code and Codex workflow harnesses in the current repository.
675
701
  It writes `.claude/skills/nodii-build-workflow/SKILL.md`, merges SessionStart,
676
- Stop and Bash PostToolUse hooks into `.claude/settings.json`, and maintains one
702
+ Stop and tool hooks into `.claude/settings.json` and `.codex/hooks.json`, and maintains one
677
703
  marked section in an existing `CLAUDE.md` or `AGENTS.md` (creating `CLAUDE.md`
678
704
  when neither exists). Existing settings and hooks are preserved. The default
679
705
  is a read-only preview; `--yes` applies, and `--check` is suitable for drift
package/dist/cli.js CHANGED
@@ -20,11 +20,12 @@ import {
20
20
  refuseFromLane,
21
21
  revokeOAuth,
22
22
  } from "./oauth.js";
23
- import { heartbeatCurrentLane, postHook, postHookFailure, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
23
+ import { heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
24
24
  import { setupCommand } from "./setup.js";
25
25
  import { harnessCommand } from "./harness.js";
26
- import { safetyPostHook, safetyPreHook } from "./safety.js";
26
+ import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
27
27
  import { doctorCommand } from "./doctor.js";
28
+ import { dispatchManagedHook } from "./managed-hooks.js";
28
29
  import {
29
30
  figmaChangeSetShow,
30
31
  figmaChangeSetText,
@@ -120,9 +121,10 @@ Usage: nodii-dev <command> [options] [--json]
120
121
  figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
121
122
  figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
122
123
  # register a Figma file in Dev so it can be locked
123
- hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|print-settings
124
+ hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
124
125
  setup [--yes|--check] # dry-run by default
125
- harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run]
126
+ harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
127
+ # --system stages managed hooks and only prints operator-reviewed sudo commands
126
128
  harness check # 0 current, 1 differs from Dev, 2 not installed
127
129
  harness update --check # the same check
128
130
  docs check|validate|upload <path...> --class ruling|conformance|editorial
@@ -757,15 +759,28 @@ export async function main(argv = process.argv.slice(2)) {
757
759
  if (command === "hook") {
758
760
  if (action === "print-settings")
759
761
  return console.log(JSON.stringify(settingsSnippet(), null, 2));
760
- if (action === "claude-figma-pre") {
761
- // Fail closed: Claude Code only blocks a tool call on exit code 2, and
762
- // treats any other non-zero exit as a non-blocking error that lets the
763
- // write through. Every failure here (bad stdin, missing config, Dev
764
- // error, lock denied) must therefore exit 2.
762
+ if (action === "managed-dispatch") {
763
+ if (!rest[0] || rest.length > 1) throw new CliError("USAGE", "hook managed-dispatch needs one event name", 2);
764
+ const output = await dispatchManagedHook(rest[0], await stdinText());
765
+ if (output) process.stdout.write(output);
766
+ return;
767
+ }
768
+ if (["claude-figma-pre", "figma-pre"].includes(action)) {
769
+ let payload;
765
770
  try {
766
- const payload = JSON.parse(await stdinText());
767
- return emit(await preHook(payload), json, "allowed");
771
+ payload = JSON.parse(await stdinText());
772
+ await recordHookFire(payload).catch(() => {});
773
+ const result = await preHook(payload);
774
+ if (hookRuntime(payload) !== "codex") return emit(result, json, "allowed");
775
+ return;
768
776
  } catch (error) {
777
+ // A known policy denial still blocks. Hook/runtime failures fail open
778
+ // and stay silent so a broken environment cannot stop the session.
779
+ if (!["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED"].includes(error?.code)) return;
780
+ if (payload && hookRuntime(payload) === "codex") {
781
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Blocked Figma write: ${error?.message ?? String(error)}` } }));
782
+ return;
783
+ }
769
784
  throw new CliError(
770
785
  error?.code ?? "FIGMA_HOOK_FAILED",
771
786
  `Blocked Figma write: ${error?.message ?? String(error)}`,
@@ -775,12 +790,24 @@ export async function main(argv = process.argv.slice(2)) {
775
790
  }
776
791
  }
777
792
  if (action === "safety-pre") {
778
- // A malformed or unavailable safety check must not turn into permission.
793
+ let payload;
779
794
  try {
780
- const result = await safetyPreHook(JSON.parse(await stdinText()));
781
- for (const nudge of result.nudges ?? []) console.log(nudge);
795
+ payload = JSON.parse(await stdinText());
796
+ await recordHookFire(payload).catch(() => {});
797
+ const result = await safetyPreHook(payload);
798
+ const nudges = result.nudges ?? [];
799
+ if (nudges.length && hookRuntime(payload) === "codex")
800
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
801
+ else for (const nudge of nudges) console.log(nudge);
782
802
  return;
783
803
  } catch (error) {
804
+ // Only an evaluated safety rule may deny. Discovery, parsing, git,
805
+ // config and Dev errors fail open and produce no output.
806
+ if (!String(error?.code ?? "").startsWith("SAFETY_")) return;
807
+ if (payload && hookRuntime(payload) === "codex") {
808
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: error?.message ?? String(error) } }));
809
+ return;
810
+ }
784
811
  throw new CliError(
785
812
  error?.code ?? "SAFETY_HOOK_FAILED",
786
813
  error?.message ?? String(error),
@@ -790,13 +817,12 @@ export async function main(argv = process.argv.slice(2)) {
790
817
  }
791
818
  }
792
819
  if (action === "safety-post") {
820
+ let payload;
793
821
  try {
794
- await safetyPostHook(JSON.parse(await stdinText()));
795
- } catch (error) {
796
- // A post-hook cannot undo a completed command; report state-tracking
797
- // failure without making Claude treat the completed command as failed.
798
- console.error(`nodii-dev safety post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`);
799
- }
822
+ payload = JSON.parse(await stdinText());
823
+ await recordHookFire(payload).catch(() => {});
824
+ await safetyPostHook(payload);
825
+ } catch {}
800
826
  return;
801
827
  }
802
828
  if (action === "lane-heartbeat") {
@@ -804,18 +830,31 @@ export async function main(argv = process.argv.slice(2)) {
804
830
  return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
805
831
  }
806
832
  const payload = JSON.parse(await stdinText());
833
+ await recordHookFire(payload).catch(() => {});
834
+ if (action === "session-start") {
835
+ const context = await sessionStartContext(payload);
836
+ if (context) console.log(context);
837
+ return;
838
+ }
807
839
  if (action === "workflow-post") {
808
840
  const suggestion = workflowSuggestion(payload);
809
- if (suggestion) console.log(suggestion);
841
+ if (suggestion && hookRuntime(payload) === "codex")
842
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: suggestion } }));
843
+ else if (suggestion) console.log(suggestion);
810
844
  return;
811
845
  }
812
- if (action === "claude-figma-post") {
846
+ if (["claude-figma-post", "figma-post"].includes(action)) {
813
847
  try {
814
848
  return emit(await postHook(payload), json, "logged");
815
849
  } catch (error) {
816
850
  const outcome = postHookFailure(error);
817
- console.error(outcome.message);
818
- if (outcome.exitCode) process.exitCode = outcome.exitCode;
851
+ if (!outcome.exitCode) return;
852
+ if (hookRuntime(payload) === "codex")
853
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: outcome.message } }));
854
+ else {
855
+ console.error(outcome.message);
856
+ if (outcome.exitCode) process.exitCode = outcome.exitCode;
857
+ }
819
858
  return;
820
859
  }
821
860
  }
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.25";
7
+ export const VERSION = "0.6.27";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -199,7 +199,7 @@ export async function checkLogin() {
199
199
  export const encode = (value) => encodeURIComponent(value);
200
200
  // Switches never take the next token as their value, so
201
201
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
202
- const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force"]);
202
+ const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system"]);
203
203
  export function parseArgs(argv) {
204
204
  const flags = {};
205
205
  const args = [];
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./cor
5
5
  import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
6
6
  import { inferGitContext, repoName } from "./lanes.js";
7
7
  import { figmaFileKey, figmaLockCheck, holdsSet, notRegisteredHint, scopeText } from "./figma.js";
8
+ import { hookDiagnostics } from "./hook-doctor.js";
8
9
 
9
10
  /**
10
11
  * What a lane's commands need, in Dev's route policy (staffRuleFor in Dev's
@@ -262,6 +263,25 @@ export async function doctorCommand(deps = {}) {
262
263
  }
263
264
  }
264
265
 
266
+ // 9. Runtime hooks: present, accepted by that runtime, and observed firing.
267
+ const hookRoot = context?.worktree ?? deps.cwd ?? process.cwd();
268
+ try {
269
+ const runtimes = await (deps.hookDiagnostics ?? hookDiagnostics)(hookRoot);
270
+ for (const runtime of runtimes) {
271
+ const key = `hooks_${runtime.runtime.replace(/[^a-z0-9]+/gi, "_")}`;
272
+ const extra = { runtime: runtime.runtime, installed: runtime.installed, trusted: runtime.trusted, firing: runtime.firing, managed: runtime.managed, lastFiredAt: runtime.lastFiredAt ?? null, ...(runtime.cliStatus ? { cliStatus: runtime.cliStatus } : {}) };
273
+ if (runtime.installed === null) checks.push(cannot(key, runtime.error ?? "runtime hook registry unavailable", extra));
274
+ else if (!runtime.installed) checks.push(missing(key, "hooks are not installed", "nodii-dev harness install (or setup --yes)", extra));
275
+ else if (!runtime.trusted) checks.push(missing(key, `hooks are installed but not trusted${runtime.trustStatuses?.length ? ` (${runtime.trustStatuses.join(", ")})` : ""}`, runtime.runtime.startsWith("codex") ? "review /hooks, or have an operator run the commands printed by `nodii-dev harness update --system`" : "review the runtime hook configuration", extra));
276
+ else if (runtime.managed && runtime.cliStatus?.ok === false) checks.push(missing(key, `${runtime.cliStatus.message}; managed hooks fail open until this is fixed`, "update nodii-dev, then run `nodii-dev harness update --system` to refresh the absolute CLI path", extra));
277
+ else if (!runtime.firing) checks.push(missing(key, `hooks are installed and trusted${runtime.managed ? " (managed)" : ""}, but no firing receipt exists`, "start a fresh runtime session and run doctor again", extra));
278
+ else checks.push(ok(key, `installed, trusted${runtime.managed ? " (managed)" : ""}, and firing (last ${runtime.lastFiredAt})`, extra));
279
+ }
280
+ } catch (error) {
281
+ checks.push(cannot("hooks_claude", error?.message ?? String(error)));
282
+ checks.push(cannot("hooks_codex", error?.message ?? String(error)));
283
+ }
284
+
265
285
  const ready = checks.every((x) => x.status === "ok");
266
286
  const width = Math.max(...checks.map((x) => x.key.length));
267
287
  const human = (p) => [
package/dist/harness.js CHANGED
@@ -7,9 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
8
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
  import { dirname, join, normalize, sep } from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { promisify } from "node:util";
11
12
  import { CliError, devRequest, query } from "./core.js";
12
- import { safetySettingsSnippet } from "./safety.js";
13
+ import { managedHookBundle, nodiiCommandArgs, writeManagedHookBundle } from "./managed-hooks.js";
14
+ import { codexSafetyHooksSnippet, regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
13
15
 
14
16
  /** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
15
17
  export const STATE_PATH = ".nodii/harness-state.json";
@@ -35,7 +37,7 @@ async function optional(path) {
35
37
 
36
38
  // The only files Dev generates (AD-era contract §7). Anything else, such as a
37
39
  // git hook or a CI workflow, is refused whatever the disk's case rules.
38
- const HARNESS_PATHS = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^\.claude\/settings\.json$/, /^\.nodii\/harness\.json$/, /^\.claude\/skills\/nodii-[a-z0-9][a-z0-9._-]*\/SKILL\.md$/];
40
+ const HARNESS_PATHS = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^\.claude\/settings\.json$/, /^\.codex\/hooks\.json$/, /^\.nodii\/harness\.json$/, /^\.claude\/skills\/nodii-[a-z0-9][a-z0-9._-]*\/SKILL\.md$/];
39
41
 
40
42
  /** A generated path must be one of the harness files the contract names. */
41
43
  export function safeRelative(path) {
@@ -142,9 +144,47 @@ export function checkHarness(harness) {
142
144
  });
143
145
  }
144
146
 
147
+ const CODEX_EVENTS = new Set([
148
+ "SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse",
149
+ "PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop", "Interrupt", "SessionEnd",
150
+ ]);
151
+
152
+ function addHookEntries(hooks, additions) {
153
+ const out = { ...hooks };
154
+ for (const [event, entries] of Object.entries(additions ?? {})) {
155
+ out[event] = [...(out[event] ?? [])];
156
+ for (const entry of entries) if (!out[event].some((existing) => stable(existing) === stable(entry))) out[event].push(entry);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ function assertNodiiHooks(hooks, path) {
162
+ for (const [event, entries] of Object.entries(hooks ?? {}))
163
+ for (const entry of entries)
164
+ for (const handler of entry.hooks ?? [])
165
+ if (handler.type !== "command")
166
+ throw new CliError("HARNESS_INVALID", `${path} ${event} hooks may run nodii-dev commands only (MW5-20).`, 1);
167
+ else try { nodiiCommandArgs(handler.command); }
168
+ catch { throw new CliError("HARNESS_INVALID", `${path} ${event} hooks may run one nodii-dev command only (MW5-20).`, 1); }
169
+ }
170
+
171
+ export function codexHooksFromClaude(hooks = {}) {
172
+ const out = {};
173
+ for (const [event, entries] of Object.entries(hooks)) {
174
+ if (!CODEX_EVENTS.has(event)) continue;
175
+ out[event] = entries.map((entry) => {
176
+ if (!["PreToolUse", "PermissionRequest", "PostToolUse"].includes(event) || !entry.matcher) return structuredClone(entry);
177
+ const translated = entry.matcher.replace(/\b(?:Edit|Write|MultiEdit|NotebookEdit)\b/g, "apply_patch");
178
+ const matcher = translated.replace(/\|apply_patch(?=(?:\|apply_patch)+\))/g, "");
179
+ return { ...structuredClone(entry), matcher };
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+
145
185
  // Safety enforcement is executable CLI behaviour, not methodology prose. Add
146
- // its nodii-dev-only hook entries to the settings fragment Dev generated so a
147
- // normal harness install/update always installs the matching CLI hooks.
186
+ // its nodii-dev-only entries for both runtimes. Codex receives the same
187
+ // lifecycle blocks with runtime-specific tool matchers.
148
188
  export function withSafetyHooks(harness) {
149
189
  checkHarness(harness);
150
190
  const copy = { ...harness, files: harness.files.map((file) => ({ ...file })) };
@@ -156,13 +196,25 @@ export function withSafetyHooks(harness) {
156
196
  if (settings.mode !== "merge-json")
157
197
  throw new CliError("HARNESS_INVALID", "Dev must generate .claude/settings.json as merge-json so nodii-dev can install safety hooks.", 1);
158
198
  const parsed = JSON.parse(settings.content);
159
- const hooks = { ...(parsed.hooks ?? {}) };
160
- for (const [event, entries] of Object.entries(safetySettingsSnippet().hooks)) {
161
- hooks[event] = [...(hooks[event] ?? [])];
162
- for (const entry of entries) if (!hooks[event].some((existing) => stable(existing) === stable(entry))) hooks[event].push(entry);
163
- }
199
+ let hooks = addHookEntries(parsed.hooks, safetySettingsSnippet().hooks);
200
+ hooks = addHookEntries(hooks, regroundingHooksSnippet().hooks);
201
+ assertNodiiHooks(hooks, ".claude/settings.json");
164
202
  settings.content = JSON.stringify({ ...parsed, hooks });
165
203
  settings.sha256 = sha(settings.content);
204
+
205
+ let codex = copy.files.find((file) => file.path === ".codex/hooks.json");
206
+ if (!codex) {
207
+ codex = { path: ".codex/hooks.json", mode: "merge-json", kind: "settings", content: "{}" };
208
+ copy.files.push(codex);
209
+ }
210
+ if (codex.mode !== "merge-json")
211
+ throw new CliError("HARNESS_INVALID", "Dev must generate .codex/hooks.json as merge-json so nodii-dev can install Codex hooks.", 1);
212
+ const codexParsed = JSON.parse(codex.content);
213
+ let codexHooks = addHookEntries(codexParsed.hooks, codexHooksFromClaude(hooks));
214
+ codexHooks = addHookEntries(codexHooks, codexSafetyHooksSnippet().hooks);
215
+ assertNodiiHooks(codexHooks, ".codex/hooks.json");
216
+ codex.content = JSON.stringify({ ...codexParsed, hooks: codexHooks });
217
+ codex.sha256 = sha(codex.content);
166
218
  return copy;
167
219
  }
168
220
 
@@ -181,7 +233,7 @@ export async function planHarness(root, harness, { install, adopt = [], request
181
233
  const owned = state?.files ?? {};
182
234
  const adopted = new Set(adopt.map((path) => {
183
235
  try { return safeRelative(path); }
184
- catch { throw new CliError("USAGE", `--adopt ${path} is not a harness file (CLAUDE.md, AGENTS.md, .claude/settings.json, .claude/skills/nodii-CODE/SKILL.md).`, 2); }
236
+ catch { throw new CliError("USAGE", `--adopt ${path} is not a harness file (CLAUDE.md, AGENTS.md, .claude/settings.json, .codex/hooks.json, .claude/skills/nodii-CODE/SKILL.md).`, 2); }
185
237
  }));
186
238
  const changes = [];
187
239
  const conflicts = [];
@@ -315,6 +367,21 @@ export async function repoContext(cwd = process.cwd()) {
315
367
  const flagList = (value) => (value === undefined || value === true ? [] : [value].flat().flatMap((x) => String(x).split(",")).filter(Boolean));
316
368
  const changeLine = (c) => `${{ create: "+", remove: "-" }[c.action] ?? "~"} ${c.path}${["create", "update", "remove"].includes(c.action) ? "" : ` (${c.action})`}`;
317
369
 
370
+ const shellQuote = (value) => `'${String(value).replaceAll("'", `'\\''`)}'`;
371
+
372
+ /**
373
+ * Print-only operator plan for the managed Codex layer. The CLI deliberately
374
+ * never invokes sudo: an operator reviews the generated hooks before copying
375
+ * the exact repository file into Codex's trusted system layer.
376
+ */
377
+ export function systemHookInstallPlan(bundle) {
378
+ const source = join(bundle.dir, "requirements.toml");
379
+ return [
380
+ "sudo install -d -m 0755 /etc/codex",
381
+ `sudo install -m 0644 ${shellQuote(source)} /etc/codex/requirements.toml`,
382
+ ];
383
+ }
384
+
318
385
  // What each planned change means for a check: how the file on disk differs from Dev's.
319
386
  const DIFFERENCE = {
320
387
  create: "missing; Dev would create it",
@@ -370,8 +437,8 @@ async function checkCommand(root, repo, state, fetchHarness) {
370
437
  };
371
438
  }
372
439
 
373
- export async function harnessCommand(action, flags, { request = devRequest, context = repoContext } = {}) {
374
- if (!["install", "update", "check"].includes(action)) throw new CliError("USAGE", "harness install|update|check [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check]", 2);
440
+ export async function harnessCommand(action, flags, { request = devRequest, context = repoContext, managedHome, managedCliPath = fileURLToPath(new URL("../bin/nodii-dev", import.meta.url)), writeManaged = writeManagedHookBundle } = {}) {
441
+ if (!["install", "update", "check"].includes(action)) throw new CliError("USAGE", "harness install|update|check [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check] [--system]", 2);
375
442
  if (flags.fleet || flags.all) throw new CliError("USAGE", "The harness is installed one service at a time, in that service's repository. There is no fleet mode.", 2);
376
443
  if (flags.root === true || flags.repo === true) throw new CliError("USAGE", "--root and --repo need a value.", 2);
377
444
  const ctx = flags.root ? { root: flags.root, repo: flags.repo } : await context();
@@ -382,18 +449,33 @@ export async function harnessCommand(action, flags, { request = devRequest, cont
382
449
  const saved = action === "install" ? {} : (state?.request ?? {});
383
450
  const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
384
451
  const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
385
- const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
386
- const fetchHarness = async () => {
387
- const response = await request("GET", `/api/v1/harness${query(params)}`);
388
- return { ...response, data: withSafetyHooks(response.data) };
452
+ let effectiveIncludeDrafts = includeDrafts;
453
+ const fetchHarness = async ({ draftFallback = false } = {}) => {
454
+ const get = () => request("GET", `/api/v1/harness${query({ repo, serviceId, includeDrafts: effectiveIncludeDrafts ? "true" : undefined })}`);
455
+ let response;
456
+ let fallback = false;
457
+ try { response = await get(); }
458
+ catch (error) {
459
+ if (!draftFallback || effectiveIncludeDrafts || error?.code !== "METHODOLOGY_NOT_AVAILABLE") throw error;
460
+ effectiveIncludeDrafts = true;
461
+ fallback = true;
462
+ response = await get();
463
+ }
464
+ const sources = response.data?.sources ?? [];
465
+ const draftSources = sources.filter((source) => source.status !== "ratified");
466
+ const draftNotice = draftSources.length
467
+ ? `${fallback || draftSources.length === sources.length ? "No methodology is ratified; using latest draft methodology" : "Using draft methodology"} ${response.data.methodologyVersion} (${draftSources.map((source) => `${source.code} ${source.version}`).join(", ")}).`
468
+ : null;
469
+ const data = draftNotice ? { ...response.data, warnings: [...(response.data.warnings ?? []), draftNotice] } : response.data;
470
+ return { ...response, data: withSafetyHooks(data) };
389
471
  };
390
472
 
391
473
  if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
392
- if (action === "check" && (flags["dry-run"] || flags.adopt)) throw new CliError("USAGE", "harness check writes nothing; --dry-run and --adopt go with install and update.", 2);
474
+ if (action === "check" && (flags["dry-run"] || flags.adopt || flags.system)) throw new CliError("USAGE", "harness check writes nothing; --dry-run, --adopt and --system go with install and update.", 2);
393
475
  if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state, fetchHarness);
394
476
 
395
- const harness = (await fetchHarness()).data;
396
- const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
477
+ const harness = (await fetchHarness({ draftFallback: true })).data;
478
+ const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts: effectiveIncludeDrafts } });
397
479
  const summary = {
398
480
  repo: harness.repo, serviceId: harness.serviceId ?? null,
399
481
  from: state?.methodologyVersion ?? null, to: harness.methodologyVersion,
@@ -411,7 +493,18 @@ export async function harnessCommand(action, flags, { request = devRequest, cont
411
493
  throw new CliError("HARNESS_CONFLICT",
412
494
  `Nothing written. These files are not nodii-dev's to change:\n${plan.conflicts.map((c) => ` ${c.path}: ${c.reason}`).join("\n")}\n` +
413
495
  "Restore files nodii-dev wrote (git checkout, or remove your edits). For your own files, remove them or let nodii-dev take them over with --adopt PATH[,PATH].", 1, summary);
414
- if (flags["dry-run"]) return { payload: { data: { ...summary, dryRun: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? "Dry run: nothing written." : "No changes."}` };
496
+ const codexChange = plan.changes.find((change) => change.path === ".codex/hooks.json");
497
+ const codexText = codexChange?.text ?? await optional(join(ctx.root, ".codex/hooks.json"));
498
+ const managedBundle = flags.system ? managedHookBundle(codexText, { home: managedHome, cliPath: managedCliPath }) : null;
499
+ const systemCommands = managedBundle ? systemHookInstallPlan(managedBundle) : [];
500
+ if (flags["dry-run"]) {
501
+ const preview = managedBundle ? `\nWould stage managed Codex hooks in ${managedBundle.dir}. Dry run: no managed files were written and no sudo commands are printed until they exist.` : "";
502
+ return { payload: { data: { ...summary, dryRun: true, ...(managedBundle ? { managedDir: managedBundle.dir } : {}) } }, human: `${lines.join("\n")}\n${plan.changes.length ? "Dry run: nothing written." : "No changes."}${preview}` };
503
+ }
504
+ const systemText = systemCommands.length
505
+ ? `\nManaged Codex hooks are staged in ${managedBundle.dir}. One operator install covers codex-personal and codex-work; review ${join(managedBundle.dir, "requirements.toml")}, then run:\n${systemCommands.join("\n")}\nNo privileged command was run. Later --system updates refresh the user-owned managed directory without sudo.`
506
+ : "";
415
507
  await applyHarness(ctx.root, plan);
416
- return { payload: { data: { ...summary, applied: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? `${action === "install" ? "Installed" : "Updated"}: ${plan.changes.length} change${plan.changes.length === 1 ? "" : "s"}.` : "Harness already current."}` };
508
+ if (managedBundle) await writeManaged(managedBundle);
509
+ return { payload: { data: { ...summary, applied: true, ...(systemCommands.length ? { systemCommands, managedDir: managedBundle.dir } : {}) } }, human: `${lines.join("\n")}\n${plan.changes.length ? `${action === "install" ? "Installed" : "Updated"}: ${plan.changes.length} change${plan.changes.length === 1 ? "" : "s"}.` : "Harness already current."}${systemText}` };
417
510
  }
@@ -0,0 +1,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFile, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { HOOK_EVENTS } from "./safety.js";
9
+ import { managedHookCliStatus } from "./managed-hooks.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ async function optional(path) {
14
+ try { return await readFile(path, "utf8"); }
15
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
16
+ }
17
+
18
+ const nodiiEntries = (hooks = {}) => Object.values(hooks).flat().filter((entry) =>
19
+ (entry.hooks ?? []).some((hook) => hook.type === "command" && /^nodii-dev(?:\s|$)/.test(String(hook.command ?? ""))),
20
+ );
21
+
22
+ async function firingEvents(root) {
23
+ try {
24
+ const path = (await execFileAsync("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-path", HOOK_EVENTS], { encoding: "utf8" })).stdout.trim();
25
+ const text = await optional(path);
26
+ return String(text ?? "").trim().split("\n").filter(Boolean).flatMap((line) => {
27
+ try { return [JSON.parse(line)]; } catch { return []; }
28
+ });
29
+ } catch { return []; }
30
+ }
31
+
32
+ /** Read the hook registry Codex itself resolved for this cwd. */
33
+ export function codexHooksList(cwd, { codexBin = "codex", spawnImpl = spawn, timeoutMs = 5_000, env = process.env } = {}) {
34
+ return new Promise((resolve, reject) => {
35
+ const child = spawnImpl(codexBin, ["app-server", "--stdio"], { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
36
+ const pidFile = join(tmpdir(), `nodii-dev-codex-hooks-${child.pid ?? "starting"}-${randomUUID()}.pid`);
37
+ let stdout = "";
38
+ let stderr = "";
39
+ let listed = null;
40
+ let settled = false;
41
+ let forceTimer;
42
+ let pidSaved = Promise.reject(new Error("Codex process did not start"));
43
+ pidSaved.catch(() => {});
44
+ const cleanup = async () => { clearTimeout(timer); clearTimeout(forceTimer); await rm(pidFile, { force: true }).catch(() => {}); };
45
+ const finish = async (error) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ await cleanup();
49
+ if (error) reject(error); else resolve(listed ?? []);
50
+ };
51
+ const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`);
52
+ const timer = setTimeout(() => {
53
+ child.stdin.end();
54
+ forceTimer = setTimeout(async () => {
55
+ try { await pidSaved; child.kill("SIGTERM"); } catch {}
56
+ }, 1_000);
57
+ }, timeoutMs);
58
+ child.on("spawn", () => { pidSaved = writeFile(pidFile, `${child.pid}\n`, { mode: 0o600 }); pidSaved.catch(() => {}); });
59
+ child.on("error", (error) => finish(error));
60
+ child.stderr.on("data", (chunk) => { if (stderr.length < 4_096) stderr += chunk; });
61
+ child.stdout.on("data", (chunk) => {
62
+ stdout += chunk;
63
+ for (;;) {
64
+ const newline = stdout.indexOf("\n");
65
+ if (newline < 0) break;
66
+ const line = stdout.slice(0, newline);
67
+ stdout = stdout.slice(newline + 1);
68
+ if (!line) continue;
69
+ let message;
70
+ try { message = JSON.parse(line); } catch { continue; }
71
+ if (message.id === 1) {
72
+ send({ method: "initialized", params: {} });
73
+ send({ id: 2, method: "hooks/list", params: { cwd } });
74
+ } else if (message.id === 2) {
75
+ if (message.error) { child.stdin.end(); finish(new Error(message.error.message ?? "Codex hooks/list failed")); return; }
76
+ listed = message.result?.data ?? [];
77
+ child.stdin.end();
78
+ }
79
+ }
80
+ });
81
+ child.on("exit", (code) => {
82
+ if (listed) finish();
83
+ else finish(new Error(`codex app-server exited ${code ?? "without a status"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
84
+ });
85
+ send({ id: 1, method: "initialize", params: { clientInfo: { name: "nodii-dev-doctor-probe", title: "nodii-dev doctor probe", version: "0.0.0" } } });
86
+ });
87
+ }
88
+
89
+ export async function hookDiagnostics(root, deps = {}) {
90
+ const events = await (deps.firingEvents ?? firingEvents)(root);
91
+ const fired = (runtime) => events.filter((event) => event.runtime === runtime || event.profile === runtime).at(-1) ?? null;
92
+ let claude = {};
93
+ try { claude = JSON.parse((await optional(join(root, ".claude/settings.json"))) ?? "{}"); }
94
+ catch { claude = {}; }
95
+ const claudeInstalled = nodiiEntries(claude.hooks).length > 0;
96
+ const out = [{
97
+ runtime: "claude", installed: claudeInstalled, trusted: claudeInstalled,
98
+ firing: Boolean(fired("claude")), lastFiredAt: fired("claude")?.at ?? null,
99
+ managed: false,
100
+ }];
101
+ const profiles = deps.codexProfiles ?? [
102
+ { runtime: "codex-personal", home: join(homedir(), ".codex") },
103
+ { runtime: "codex-work", home: join(homedir(), ".codex-work") },
104
+ ];
105
+ const cliStatus = await (deps.managedHookCliStatus ?? managedHookCliStatus)({ home: deps.home ?? homedir() });
106
+ for (const profile of profiles) {
107
+ try {
108
+ const data = await (deps.codexHooksList ?? codexHooksList)(root, { ...deps, env: { ...(deps.env ?? process.env), CODEX_HOME: profile.home } });
109
+ const record = data.find((item) => item.cwd === root) ?? data[0] ?? {};
110
+ const hooks = record.hooks ?? [];
111
+ const managedHooks = hooks.filter((hook) => hook.isManaged === true || hook.trustStatus === "managed");
112
+ const trustedHooks = hooks.filter((hook) => hook.isManaged === true || ["managed", "trusted"].includes(hook.trustStatus));
113
+ out.push({
114
+ runtime: profile.runtime, profileHome: profile.home, installed: hooks.length > 0, trusted: managedHooks.length > 0 || (hooks.length > 0 && trustedHooks.length === hooks.length),
115
+ firing: Boolean(fired(profile.runtime)), lastFiredAt: fired(profile.runtime)?.at ?? null,
116
+ managed: managedHooks.length > 0, managedCount: managedHooks.length,
117
+ ...(managedHooks.length ? { cliStatus } : {}),
118
+ trustStatuses: [...new Set(hooks.map((hook) => hook.trustStatus ?? "unknown"))],
119
+ warnings: record.warnings ?? [], errors: record.errors ?? [],
120
+ });
121
+ } catch (error) {
122
+ out.push({ runtime: profile.runtime, profileHome: profile.home, installed: null, trusted: null, firing: Boolean(fired(profile.runtime)), lastFiredAt: fired(profile.runtime)?.at ?? null, managed: null, error: error.message });
123
+ }
124
+ }
125
+ return out;
126
+ }
package/dist/hooks.js CHANGED
@@ -1,6 +1,8 @@
1
- import { CliError, devRequest } from "./core.js";
1
+ import { CliError, devRequest, readConfig } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
3
  import { execFile } from "node:child_process";
4
+ import { realpath } from "node:fs/promises";
5
+ import { resolve } from "node:path";
4
6
  import { promisify } from "node:util";
5
7
  import { safetySettingsSnippet } from "./safety.js";
6
8
 
@@ -214,6 +216,34 @@ export async function heartbeatCurrentLane(request = devRequest) {
214
216
  return { heartbeated: true, key: lane.key };
215
217
  }
216
218
 
219
+ /** SessionStart compact context, plain text for both Claude and Codex. */
220
+ export async function sessionStartContext(payload, { request = devRequest, config = readConfig } = {}) {
221
+ if (payload?.source !== "compact") return null;
222
+ let lane = null;
223
+ try {
224
+ const cwd = payload.cwd ?? process.cwd();
225
+ const branch = (await execFileAsync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" })).stdout.trim();
226
+ const rootText = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim();
227
+ const root = await realpath(rootText).catch(() => resolve(rootText));
228
+ const current = await config(false);
229
+ const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
230
+ for (const item of lanes) {
231
+ if (item.agentId !== current.agentId || item.branch !== branch || ["on_dev", "stopped"].includes(item.status)) continue;
232
+ const worktree = await realpath(item.worktree ?? root).catch(() => resolve(item.worktree ?? root));
233
+ if (worktree === root) { lane = item; break; }
234
+ }
235
+ } catch {}
236
+ if (!lane) return "Freeze re-grounding: no active Dev lane was available for this branch; re-read the lane brief before continuing.";
237
+ const freeze = lane.freeze ?? {};
238
+ return [
239
+ `Freeze re-grounding for ${lane.key}:`,
240
+ `title: ${lane.title ?? "(untitled)"}`,
241
+ `status: ${lane.status}`,
242
+ `boundaries: ${JSON.stringify(freeze.boundaries ?? {})}`,
243
+ `rules: ${JSON.stringify(freeze.rules ?? freeze.ruleRefs ?? [])}`,
244
+ ].join("\n");
245
+ }
246
+
217
247
  export function workflowSuggestion(payload) {
218
248
  const command = payload?.tool_input?.command ?? "";
219
249
  if (/\bgit\s+push\b/.test(command)) return "nodii-dev: pushed work; record it with `nodii-dev lane evidence <key> --commit <sha>` or `--pr <url>`.";
@@ -0,0 +1,169 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, isAbsolute, join } from "node:path";
6
+ import { CliError, VERSION } from "./core.js";
7
+
8
+ export const CODEX_HOOK_EVENTS = [
9
+ "SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse",
10
+ "PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop", "Interrupt", "SessionEnd",
11
+ ];
12
+
13
+ export const managedHooksDir = (home = homedir()) => join(home, ".config", "nodii-dev", "codex-managed-hooks");
14
+ export const MANAGED_DISPATCH_MIN_VERSION = "0.6.27";
15
+
16
+ /** Only a single nodii-dev invocation is allowed; shell composition is never accepted. */
17
+ export function nodiiCommandArgs(command) {
18
+ const text = String(command ?? "").trim();
19
+ if (!text.startsWith("nodii-dev") || /[;&|<>`$\\\r\n]/.test(text))
20
+ throw new CliError("HARNESS_INVALID", `Managed hooks may run one nodii-dev command only: ${command}`, 1);
21
+ const words = text.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
22
+ if (words.shift() !== "nodii-dev") throw new CliError("HARNESS_INVALID", `Managed hooks may run nodii-dev commands only: ${command}`, 1);
23
+ return words.map((word) => /^(['"]).*\1$/.test(word) ? word.slice(1, -1) : word);
24
+ }
25
+
26
+ export function validateManagedHooks(hooks = {}) {
27
+ for (const [event, entries] of Object.entries(hooks)) {
28
+ if (!CODEX_HOOK_EVENTS.includes(event)) throw new CliError("HARNESS_INVALID", `Codex does not support managed hook event ${event}.`, 1);
29
+ for (const entry of entries)
30
+ for (const handler of entry.hooks ?? []) {
31
+ if (handler.type !== "command") throw new CliError("HARNESS_INVALID", `Managed ${event} hooks must be command handlers.`, 1);
32
+ nodiiCommandArgs(handler.command);
33
+ }
34
+ }
35
+ }
36
+
37
+ const tomlString = (value) => JSON.stringify(String(value));
38
+ const shellString = (value) => `'${String(value).replaceAll("'", `'\\''`)}'`;
39
+ const laneConfig = (env) => /^lane-[a-z0-9_-]+\.json$/i.test(basename(env.NODII_DEV_CONFIG_PATH ?? ""));
40
+ const emptyResponse = (event) => ["Stop", "SubagentStop"].includes(event) ? "{}\n" : "";
41
+
42
+ /** Stable managed policy: all later hook changes live in user-owned hooks.json. */
43
+ export function managedRequirements(dir) {
44
+ const dispatcher = join(dir, "dispatch");
45
+ return [
46
+ "[features]",
47
+ "hooks = true",
48
+ "",
49
+ "[hooks]",
50
+ `managed_dir = ${tomlString(dir)}`,
51
+ "",
52
+ ...CODEX_HOOK_EVENTS.flatMap((event) => [
53
+ `[[hooks.${event}]]`,
54
+ 'matcher = ".*"',
55
+ "",
56
+ `[[hooks.${event}.hooks]]`,
57
+ 'type = "command"',
58
+ `command = ${tomlString(`${dispatcher} ${event}`)}`,
59
+ "timeout = 30",
60
+ 'statusMessage = "Applying Nodii harness policy"',
61
+ "",
62
+ ]),
63
+ ].join("\n");
64
+ }
65
+
66
+ export function managedHookBundle(hooksText, { home, cliPath, cliVersion = VERSION } = {}) {
67
+ let parsed;
68
+ try { parsed = JSON.parse(String(hooksText ?? "")); }
69
+ catch { throw new CliError("HARNESS_SETTINGS_INVALID", ".codex/hooks.json is not valid JSON.", 2); }
70
+ validateManagedHooks(parsed.hooks ?? {});
71
+ if (!isAbsolute(String(cliPath ?? ""))) throw new CliError("HARNESS_INVALID", "The managed Codex dispatcher needs the absolute nodii-dev executable path.", 1);
72
+ const dir = managedHooksDir(home);
73
+ const command = shellString(cliPath);
74
+ const dispatch = `#!/bin/sh\nmanaged_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nmanaged_cli=${command}\nempty_response() {\n case "$1" in Stop|SubagentStop) printf '{}\\n' ;; esac\n}\ncase "\${NODII_DEV_CONFIG_PATH##*/}" in lane-*.json) ;; *) empty_response "$1"; exit 0 ;; esac\nif [ ! -x "$managed_cli" ]; then empty_response "$1"; exit 0; fi\nexport NODII_DEV_MANAGED_HOOKS_DIR="$managed_dir"\nexport NODII_DEV_MANAGED_CLI_PATH="$managed_cli"\nmanaged_output=$("$managed_cli" hook managed-dispatch "$1" 2>/dev/null)\nmanaged_status=$?\nif [ "$managed_status" -ne 0 ]; then empty_response "$1"; exit 0; fi\nif [ -n "$managed_output" ]; then printf '%s\\n' "$managed_output"; else empty_response "$1"; fi\n`;
75
+ const metadata = `${JSON.stringify({ cliPath, cliVersion, minimumVersion: MANAGED_DISPATCH_MIN_VERSION }, null, 2)}\n`;
76
+ return { dir, hooksText: `${JSON.stringify({ hooks: parsed.hooks ?? {} }, null, 2)}\n`, dispatch, metadata, requirements: managedRequirements(dir) };
77
+ }
78
+
79
+ export async function writeManagedHookBundle(bundle) {
80
+ await mkdir(bundle.dir, { recursive: true, mode: 0o700 });
81
+ await writeFile(join(bundle.dir, "hooks.json"), bundle.hooksText, { mode: 0o600 });
82
+ await writeFile(join(bundle.dir, "dispatch"), bundle.dispatch, { mode: 0o700 });
83
+ await chmod(join(bundle.dir, "dispatch"), 0o700);
84
+ await writeFile(join(bundle.dir, "metadata.json"), bundle.metadata, { mode: 0o600 });
85
+ await writeFile(join(bundle.dir, "requirements.toml"), bundle.requirements, { mode: 0o600 });
86
+ }
87
+
88
+ function runHandler(args, input, { spawnImpl = spawn, env = process.env } = {}) {
89
+ return new Promise((resolve) => {
90
+ const child = spawnImpl(env.NODII_DEV_MANAGED_CLI_PATH ?? "nodii-dev", args, { env, stdio: ["pipe", "pipe", "pipe"] });
91
+ let stdout = "";
92
+ let stderr = "";
93
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
94
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
95
+ child.on("error", (error) => resolve({ status: 1, stdout, stderr: `${stderr}${error.message}` }));
96
+ child.on("exit", (status) => resolve({ status: status ?? 1, stdout, stderr }));
97
+ child.stdin.end(input);
98
+ });
99
+ }
100
+
101
+ const matchTarget = (event, payload) => {
102
+ if (["PreToolUse", "PermissionRequest", "PostToolUse"].includes(event)) return payload.tool_name ?? "";
103
+ if (event === "SessionStart") return payload.source ?? "";
104
+ return payload.agent_type ?? payload.source ?? "";
105
+ };
106
+
107
+ function responseOf(stdout) {
108
+ try { return JSON.parse(stdout.trim()); } catch { return null; }
109
+ }
110
+
111
+ /** Execute the current generated hook set behind the stable managed dispatcher. */
112
+ export async function dispatchManagedHook(event, payloadText, deps = {}) {
113
+ try {
114
+ if (!CODEX_HOOK_EVENTS.includes(event)) return emptyResponse(event);
115
+ const env = deps.env ?? process.env;
116
+ if (!laneConfig(env)) return emptyResponse(event);
117
+ const dir = deps.dir ?? env.NODII_DEV_MANAGED_HOOKS_DIR ?? managedHooksDir();
118
+ const config = JSON.parse(await (deps.read ?? readFile)(join(dir, "hooks.json"), "utf8"));
119
+ validateManagedHooks(config.hooks ?? {});
120
+ const payload = JSON.parse(payloadText);
121
+ const target = matchTarget(event, payload);
122
+ const handlers = (config.hooks?.[event] ?? []).filter((entry) => {
123
+ try { return new RegExp(entry.matcher || ".*").test(target); } catch { return false; }
124
+ }).flatMap((entry) => entry.hooks ?? []);
125
+ const results = await Promise.all(handlers.map((handler) => (deps.run ?? runHandler)(nodiiCommandArgs(handler.command), payloadText, deps)));
126
+ // A failed handler is unavailable policy, not a denial. Ignore both its
127
+ // output and stderr; doctor is where hook health remains visible.
128
+ const successful = results.filter((result) => result.status === 0);
129
+ const messages = successful.map((result) => responseOf(result.stdout)).filter(Boolean);
130
+ if (["PreToolUse", "PermissionRequest"].includes(event)) {
131
+ const denied = messages.find((message) => message.hookSpecificOutput?.permissionDecision === "deny");
132
+ if (denied) return `${JSON.stringify(denied)}\n`;
133
+ const contexts = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
134
+ return contexts.length ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: contexts.join("\n") } })}\n` : "";
135
+ }
136
+ const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
137
+ const plain = successful.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
138
+ if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
139
+ return [...structured, ...plain].join("\n") + ([...structured, ...plain].length ? "\n" : "");
140
+ } catch {
141
+ return emptyResponse(event);
142
+ }
143
+ }
144
+
145
+ const versionParts = (value) => String(value ?? "").split(".").map((part) => Number.parseInt(part, 10));
146
+ const atLeast = (actual, minimum) => {
147
+ const a = versionParts(actual);
148
+ const b = versionParts(minimum);
149
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
150
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
151
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
152
+ }
153
+ return true;
154
+ };
155
+
156
+ /** The managed dispatcher deliberately fails open; doctor makes that visible. */
157
+ export async function managedHookCliStatus({ home = homedir(), read = readFile, accessImpl = access } = {}) {
158
+ let metadata;
159
+ try { metadata = JSON.parse(await read(join(managedHooksDir(home), "metadata.json"), "utf8")); }
160
+ catch (error) {
161
+ return { ok: false, reason: "metadata_missing", message: `managed hook metadata is unavailable (${error.code ?? error.message})` };
162
+ }
163
+ if (!isAbsolute(String(metadata.cliPath ?? ""))) return { ...metadata, ok: false, reason: "path_invalid", message: "managed hook nodii-dev path is not absolute" };
164
+ try { await accessImpl(metadata.cliPath, constants.X_OK); }
165
+ catch { return { ...metadata, ok: false, reason: "cli_missing", message: `managed hook CLI is missing or not executable: ${metadata.cliPath}` }; }
166
+ const minimum = metadata.minimumVersion ?? MANAGED_DISPATCH_MIN_VERSION;
167
+ if (!atLeast(metadata.cliVersion, minimum)) return { ...metadata, ok: false, reason: "cli_too_old", message: `managed hook CLI ${metadata.cliVersion ?? "unknown"} is older than ${minimum}` };
168
+ return { ...metadata, ok: true };
169
+ }
package/dist/nodii-dev.js CHANGED
@@ -1,4 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { fail, main } from "./cli.js";
3
3
 
4
- main().catch(fail);
4
+ // Hooks run in the agent's environment, which may be incomplete or may not be
5
+ // a git worktree. An unavailable hook must never turn into a refused tool call.
6
+ // Intentional policy denials are handled inside the hook command before they
7
+ // reach this boundary.
8
+ main().catch((error) => {
9
+ const denial = String(error?.code ?? "").startsWith("SAFETY_") ||
10
+ ["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "WRITE_OUT_OF_SCOPE"].includes(error?.code);
11
+ if (process.argv[2] === "hook" && !denial) {
12
+ process.exitCode = 0;
13
+ return;
14
+ }
15
+ fail(error);
16
+ });
package/dist/safety.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { execFile } from "node:child_process";
2
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
  const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
9
- const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
9
+ const SHELL_TOOLS = new Set(["Bash", "shell", "exec_command"]);
10
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]);
10
11
  const GENERATED_MARK = /(?:do not edit|auto-?generated|generated (?:file|code)|@generated)/i;
11
12
  const REVIEW_RECEIPT = "nodii-review-receipt.json";
12
13
  const SAFETY_STATE = "nodii-safety-state.json";
13
14
  const SAFETY_AUDIT = "nodii-safety-audit.jsonl";
15
+ export const HOOK_EVENTS = "nodii-hook-events.jsonl";
14
16
 
15
17
  async function optional(path) {
16
18
  try { return await readFile(path, "utf8"); }
@@ -25,6 +27,23 @@ async function gitPrivatePath(cwd, name) {
25
27
  return git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
26
28
  }
27
29
 
30
+ /** Minimal private receipt used by doctor to prove a runtime actually fired. */
31
+ export async function recordHookFire(payload) {
32
+ const cwd = payload?.cwd ?? process.cwd();
33
+ const path = await gitPrivatePath(cwd, HOOK_EVENTS);
34
+ const event = {
35
+ at: new Date().toISOString(),
36
+ runtime: hookRuntime(payload),
37
+ event: payload?.hook_event_name ?? "unknown",
38
+ ...(payload?.source ? { source: payload.source } : {}),
39
+ ...(payload?.tool_name ? { tool: payload.tool_name } : {}),
40
+ };
41
+ if (event.runtime === "codex") event.profile = basename(process.env.CODEX_HOME ?? ".codex") === ".codex-work" ? "codex-work" : "codex-personal";
42
+ await mkdir(dirname(path), { recursive: true });
43
+ await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
44
+ return event;
45
+ }
46
+
28
47
  const cleanPath = (path) => String(path).split(sep).join("/").replace(/^\.\//, "");
29
48
  const quotePattern = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
30
49
  function globPattern(glob) {
@@ -121,12 +140,20 @@ function exactDeployGrant(context) {
121
140
  return context.deployGrants.some((x) => x === context.head);
122
141
  }
123
142
 
143
+ const provenLane = (context) => context.provenLane === true || (context.provenLane === undefined && Boolean(context.lane));
144
+ const nudgeViolation = (violation) => `nodii-dev safety: unproven lane context; ${violation.message.replace(/\bblocked\b/gi, "would be blocked")}`;
145
+
124
146
  /** Data-dependent checks nudge, rather than silently passing, when their source is not available yet. */
125
147
  export function commandNudges(command, context) {
126
148
  const tokens = words(command);
127
149
  const nudges = [];
128
150
  if (deployCommand(tokens, command) && !context.deployGrantKnown)
129
151
  nudges.push("nodii-dev safety: can't verify deploy authorization: no GO grant source is configured (follow-up AGENT-HARNESS-13).");
152
+ if (gitInvocations(tokens).some((x) => x.verb === "push")) {
153
+ if (!provenLane(context)) nudges.push("nodii-dev safety: can't verify review: this is not a proven current-lane context.");
154
+ else if (!context.reviewFlowKnown) nudges.push(`nodii-dev safety: can't verify review for ${context.lane?.key ?? context.branch}: Dev's lane review receipt flow is unavailable.`);
155
+ }
156
+ if (!provenLane(context)) nudges.push(...commandViolations(command, context).map(nudgeViolation));
130
157
  return nudges;
131
158
  }
132
159
 
@@ -135,10 +162,10 @@ export function commandViolations(command, context) {
135
162
  const tokens = words(command);
136
163
  const gitCommands = gitInvocations(tokens);
137
164
  const violations = [];
138
- const lane = context.role !== "coordinator";
165
+ const lane = provenLane(context);
139
166
  const block = (code, message) => violations.push({ code, message });
140
167
 
141
- if (gitCommands.some((x) => x.verb === "stash") && lane)
168
+ if (gitCommands.some((x) => x.verb === "stash"))
142
169
  block("SAFETY_GIT_STASH", "git stash is shared by every worktree; lanes and subagents use a work-in-progress commit.");
143
170
 
144
171
  for (const verb of ["checkout", "switch"]) {
@@ -168,7 +195,7 @@ export function commandViolations(command, context) {
168
195
  else if (!context.noVerifyReason) block("SAFETY_NO_VERIFY_REASON", "A coordinator may use --no-verify only with a recorded reason.");
169
196
  }
170
197
 
171
- if (gitCommands.some((x) => x.verb === "push")) {
198
+ if (lane && context.reviewFlowKnown && gitCommands.some((x) => x.verb === "push")) {
172
199
  const receipt = context.reviewReceipt;
173
200
  if (!receipt) block("SAFETY_REVIEW_REQUIRED", `Push blocked: no review receipt for ${context.head}. Run nodii-dev lane review after the gates are green.`);
174
201
  else if (receipt.commit !== context.head)
@@ -177,7 +204,7 @@ export function commandViolations(command, context) {
177
204
  block("SAFETY_REVIEW_BASE", `Push blocked: review used base ${receipt.base}, but origin/main is ${context.base}. Re-review the current diff.`);
178
205
  }
179
206
 
180
- if (lane && /(?:^|[\s/])(?:nodii-dev|@nodii\/dev(?:@[^\s]+)?)\s+(?:login|logout|agent\s+register)(?:\s|$)/.test(command))
207
+ if (/(?:^|[\s/])(?:nodii-dev|@nodii\/dev(?:@[^\s]+)?)\s+(?:login|logout|agent\s+register)(?:\s|$)/.test(command))
181
208
  block("SAFETY_IDENTITY", "A lane never runs nodii-dev login, logout or agent register; ask the coordinator.");
182
209
  if (/\bnodii\/prod(?:\/|\b)/i.test(command))
183
210
  block("SAFETY_PROD_SECRET", "nodii/prod secrets are outside lane authority.");
@@ -207,9 +234,11 @@ export function commandViolations(command, context) {
207
234
  /** Edit-tool ownership and generated-file blocks. */
208
235
  export async function editViolations(paths, context) {
209
236
  const violations = [];
237
+ const root = await realpath(context.root).catch(() => resolve(context.root));
210
238
  for (const path of paths) {
211
- const absolute = isAbsolute(path) ? resolve(path) : resolve(context.cwd, path);
212
- const rel = cleanPath(relative(context.root, absolute));
239
+ const unresolved = isAbsolute(path) ? resolve(path) : resolve(context.cwd, path);
240
+ const absolute = await realpath(unresolved).catch(async () => join(await realpath(dirname(unresolved)).catch(() => dirname(unresolved)), basename(unresolved)));
241
+ const rel = cleanPath(relative(root, absolute));
213
242
  if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
214
243
  violations.push({ code: "SAFETY_EDIT_OUTSIDE_REPO", message: `Edit blocked outside the lane repository: ${path}.` });
215
244
  continue;
@@ -224,8 +253,22 @@ export async function editViolations(paths, context) {
224
253
  return violations;
225
254
  }
226
255
 
256
+ export function hookRuntime(payload) {
257
+ return payload?.model || payload?.turn_id || payload?.tool_name === "apply_patch" ? "codex" : "claude";
258
+ }
259
+
260
+ export function patchPaths(command) {
261
+ const paths = [];
262
+ for (const line of String(command ?? "").split(/\r?\n/)) {
263
+ const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) ?? /^\*\*\* Move to: (.+)$/.exec(line);
264
+ if (match?.[1]?.trim()) paths.push(match[1].trim());
265
+ }
266
+ return [...new Set(paths)];
267
+ }
268
+
227
269
  function editPaths(payload) {
228
270
  const input = payload.tool_input ?? {};
271
+ if (payload.tool_name === "apply_patch") return patchPaths(input.command);
229
272
  return [input.file_path, input.path, input.notebook_path, ...(Array.isArray(input.paths) ? input.paths : [])].filter((x) => typeof x === "string" && x);
230
273
  }
231
274
 
@@ -253,9 +296,15 @@ const laneStack = (key) => String(key ?? "").toLowerCase().replace(/[^a-z0-9_-]/
253
296
  async function currentLane(cwd, request, config) {
254
297
  if (!config?.agentId) return null;
255
298
  const branch = await git(cwd, "branch", "--show-current");
256
- const root = await git(cwd, "rev-parse", "--show-toplevel");
299
+ const rootText = await git(cwd, "rev-parse", "--show-toplevel");
300
+ const root = await realpath(rootText).catch(() => resolve(rootText));
257
301
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
258
- return lanes.find((x) => x.agentId === config.agentId && x.branch === branch && resolve(x.worktree ?? root) === resolve(root) && ACTIVE.has(x.status)) ?? null;
302
+ for (const lane of lanes) {
303
+ if (lane.agentId !== config.agentId || lane.branch !== branch || !ACTIVE.has(lane.status)) continue;
304
+ const worktree = await realpath(lane.worktree ?? root).catch(() => resolve(lane.worktree ?? root));
305
+ if (worktree === root) return lane;
306
+ }
307
+ return null;
259
308
  }
260
309
 
261
310
  function deployGrantsFrom(text) {
@@ -264,7 +313,7 @@ function deployGrantsFrom(text) {
264
313
 
265
314
  /** Resolve the facts a hook needs without creating a second lane record. */
266
315
  export async function safetyContext(cwd = process.cwd(), { request = devRequest, configReader = readConfig, env = process.env } = {}) {
267
- const root = await git(cwd, "rev-parse", "--show-toplevel");
316
+ const root = await realpath(await git(cwd, "rev-parse", "--show-toplevel"));
268
317
  const branch = await git(cwd, "branch", "--show-current");
269
318
  const head = await git(cwd, "rev-parse", "HEAD");
270
319
  const base = await git(cwd, "rev-parse", "origin/main");
@@ -277,6 +326,15 @@ export async function safetyContext(cwd = process.cwd(), { request = devRequest,
277
326
  }
278
327
  let lane = null;
279
328
  try { lane = await currentLane(cwd, request, config); } catch {}
329
+ let reviewFlowKnown = false;
330
+ if (lane) {
331
+ if (Array.isArray(lane.reviews)) reviewFlowKnown = true;
332
+ else try {
333
+ const detailed = (await request("GET", `/api/v1/lanes/${encode(lane.key)}`)).data;
334
+ reviewFlowKnown = Array.isArray(detailed?.reviews);
335
+ if (detailed && !Array.isArray(detailed)) lane = { ...lane, ...detailed };
336
+ } catch {}
337
+ }
280
338
  const receiptPath = await gitPrivatePath(cwd, REVIEW_RECEIPT);
281
339
  const statePath = await gitPrivatePath(cwd, SAFETY_STATE);
282
340
  const receiptText = await optional(receiptPath);
@@ -287,7 +345,7 @@ export async function safetyContext(cwd = process.cwd(), { request = devRequest,
287
345
  const ownership = laneOwnership(lane, root);
288
346
  const laneProject = laneStack(lane?.key);
289
347
  return {
290
- cwd: payloadCwd(cwd), root, branch, head, base, role, lane,
348
+ cwd: await realpath(cwd).catch(() => payloadCwd(cwd)), root, branch, head, base, role, lane, provenLane: Boolean(lane), reviewFlowKnown,
291
349
  ownedPaths: ownership.paths,
292
350
  ownershipKnown: role === "coordinator" || ownership.known,
293
351
  generatedPaths: [...await harnessGenerated(root), ...(state.generatedPaths ?? [])],
@@ -306,30 +364,37 @@ function blocked(violation) {
306
364
  throw new CliError(violation.code, violation.message, 2);
307
365
  }
308
366
 
309
- /** Claude PreToolUse entrypoint. Silent return means allowed. */
367
+ /** Runtime-neutral PreToolUse entrypoint. Silent return means allowed. */
310
368
  export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
311
369
  const tool = payload.tool_name ?? "";
312
- if (tool !== "Bash" && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
370
+ if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
313
371
  const cwd = payload.cwd ?? process.cwd();
314
372
  const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
315
- context.cwd = resolve(cwd);
316
- if (tool === "Bash") {
373
+ context.cwd = await realpath(cwd).catch(() => resolve(cwd));
374
+ if (SHELL_TOOLS.has(tool)) {
317
375
  const command = String(payload.tool_input?.command ?? "");
318
376
  const violation = commandViolations(command, context)[0];
319
- if (violation) blocked(violation);
377
+ if (violation && provenLane(context)) blocked(violation);
320
378
  if (usesNoVerify(command) && context.role === "coordinator" && context.noVerifyReason)
321
379
  await recordNoVerifyReason(cwd, context.noVerifyReason);
322
380
  return { allowed: true, nudges: commandNudges(command, context) };
323
381
  }
324
382
  const paths = editPaths(payload);
325
- if (!paths.length) blocked({ code: "SAFETY_EDIT_PATH_MISSING", message: `Edit blocked: ${tool} supplied no file path.` });
383
+ if (!paths.length) {
384
+ const violation = { code: "SAFETY_EDIT_PATH_MISSING", message: `Edit blocked: ${tool} supplied no file path.` };
385
+ if (provenLane(context)) blocked(violation);
386
+ return { allowed: true, nudges: [nudgeViolation(violation)] };
387
+ }
326
388
  const violation = (await editViolations(paths, context))[0];
327
- if (violation) blocked(violation);
389
+ if (violation && provenLane(context)) blocked(violation);
328
390
  return {
329
391
  allowed: true,
330
- nudges: context.role !== "coordinator" && !context.ownershipKnown
331
- ? ["nodii-dev safety: can't verify owned paths: the lane has no Dev ownedPaths field yet (follow-up AGENT-HARNESS-10)."]
332
- : [],
392
+ nudges: [
393
+ ...(!provenLane(context) && violation ? [nudgeViolation(violation)] : []),
394
+ ...(context.role !== "coordinator" && !context.ownershipKnown
395
+ ? ["nodii-dev safety: can't verify owned paths: the lane has no Dev ownedPaths field yet (follow-up AGENT-HARNESS-10)."]
396
+ : []),
397
+ ],
333
398
  };
334
399
  }
335
400
 
@@ -351,7 +416,7 @@ function successful(payload) {
351
416
 
352
417
  /** Record only successful resource creation; says nothing when satisfied. */
353
418
  export async function safetyPostHook(payload) {
354
- if (payload.tool_name !== "Bash" || !successful(payload)) return { recorded: false };
419
+ if (!SHELL_TOOLS.has(payload.tool_name) || !successful(payload)) return { recorded: false };
355
420
  const command = String(payload.tool_input?.command ?? "");
356
421
  const tokens = words(command);
357
422
  const cwd = payload.cwd ?? process.cwd();
@@ -402,3 +467,16 @@ export const safetySettingsSnippet = () => ({
402
467
  PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
403
468
  },
404
469
  });
470
+
471
+ export const codexSafetyHooksSnippet = () => ({
472
+ hooks: {
473
+ PreToolUse: [{ matcher: "^(Bash|apply_patch)$", hooks: [{ type: "command", command: "nodii-dev hook safety-pre" }] }],
474
+ PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
475
+ },
476
+ });
477
+
478
+ export const regroundingHooksSnippet = () => ({
479
+ hooks: {
480
+ SessionStart: [{ matcher: "^compact$", hooks: [{ type: "command", command: "nodii-dev hook session-start" }] }],
481
+ },
482
+ });
package/dist/setup.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { CliError, checkLogin } from "./core.js";
4
- import { safetySettingsSnippet } from "./safety.js";
4
+ import { codexHooksFromClaude } from "./harness.js";
5
+ import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
5
6
 
6
7
  export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
7
8
  const START = "<!-- nodii-dev build workflow:start -->";
@@ -47,7 +48,10 @@ ${END}`;
47
48
  export const workflowHooks = () => {
48
49
  const safety = safetySettingsSnippet().hooks;
49
50
  return {
50
- SessionStart: [{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] }],
51
+ SessionStart: [
52
+ { hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] },
53
+ ...regroundingHooksSnippet().hooks.SessionStart,
54
+ ],
51
55
  Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
52
56
  PreToolUse: safety.PreToolUse,
53
57
  PostToolUse: [
@@ -70,6 +74,12 @@ export function mergeSettings(current = {}) {
70
74
  return { ...current, hooks };
71
75
  }
72
76
 
77
+ export function mergeCodexHooks(current = {}) {
78
+ const hooks = { ...(current.hooks ?? {}) };
79
+ for (const [event, entries] of Object.entries(codexHooksFromClaude(workflowHooks()))) hooks[event] = mergeHookList(hooks[event], entries);
80
+ return { ...current, hooks };
81
+ }
82
+
73
83
  export function updateMarkedDocument(text = "") {
74
84
  const block = new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
75
85
  if (block.test(text)) return `${text.replace(block, managedBlock).trimEnd()}\n`;
@@ -86,12 +96,18 @@ export async function setupPlan(root = process.cwd()) {
86
96
  let parsed = {};
87
97
  try { parsed = existingSettings ? JSON.parse(existingSettings) : {}; }
88
98
  catch { throw new CliError("INVALID_SETTINGS", `${settingsPath} is not valid JSON; fix it before setup.`, 2); }
99
+ const codexHooksPath = join(root, ".codex/hooks.json");
100
+ const existingCodexHooks = await optional(codexHooksPath);
101
+ let parsedCodexHooks = {};
102
+ try { parsedCodexHooks = existingCodexHooks ? JSON.parse(existingCodexHooks) : {}; }
103
+ catch { throw new CliError("INVALID_SETTINGS", `${codexHooksPath} is not valid JSON; fix it before setup.`, 2); }
89
104
  const claude = join(root, "CLAUDE.md");
90
105
  const agents = join(root, "AGENTS.md");
91
106
  const documentPath = (await optional(claude)) !== null ? claude : (await optional(agents)) !== null ? agents : claude;
92
107
  const desired = new Map([
93
108
  [join(root, SKILL_PATH), workflowSkill],
94
109
  [settingsPath, `${JSON.stringify(mergeSettings(parsed), null, 2)}\n`],
110
+ [codexHooksPath, `${JSON.stringify(mergeCodexHooks(parsedCodexHooks), null, 2)}\n`],
95
111
  [documentPath, updateMarkedDocument((await optional(documentPath)) ?? "")],
96
112
  ]);
97
113
  const changes = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.25",
3
+ "version": "0.6.27",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {