@nodii/dev 0.6.24 → 0.6.26

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.
@@ -0,0 +1,166 @@
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.26";
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
+ if (!CODEX_HOOK_EVENTS.includes(event)) throw new CliError("USAGE", `Unknown managed Codex hook event: ${event}`, 2);
114
+ const env = deps.env ?? process.env;
115
+ if (!laneConfig(env)) return emptyResponse(event);
116
+ const dir = deps.dir ?? env.NODII_DEV_MANAGED_HOOKS_DIR ?? managedHooksDir();
117
+ let config;
118
+ try { config = JSON.parse(await (deps.read ?? readFile)(join(dir, "hooks.json"), "utf8")); }
119
+ catch (error) { throw new CliError("HARNESS_SETTINGS_INVALID", `Cannot read managed Codex hooks: ${error.message}`, 2); }
120
+ validateManagedHooks(config.hooks ?? {});
121
+ const payload = JSON.parse(payloadText);
122
+ const target = matchTarget(event, payload);
123
+ const handlers = (config.hooks?.[event] ?? []).filter((entry) => {
124
+ try { return new RegExp(entry.matcher || ".*").test(target); } catch { return false; }
125
+ }).flatMap((entry) => entry.hooks ?? []);
126
+ const results = await Promise.all(handlers.map((handler) => (deps.run ?? runHandler)(nodiiCommandArgs(handler.command), payloadText, deps)));
127
+ const messages = results.map((result) => responseOf(result.stdout)).filter(Boolean);
128
+ const errors = results.filter((result) => result.status !== 0).map((result) => result.stderr.trim() || result.stdout.trim()).filter(Boolean);
129
+ if (["PreToolUse", "PermissionRequest"].includes(event)) {
130
+ const denied = messages.find((message) => message.hookSpecificOutput?.permissionDecision === "deny");
131
+ if (denied) return `${JSON.stringify(denied)}\n`;
132
+ if (errors.length) return `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, permissionDecision: "deny", permissionDecisionReason: errors.join("\n") } })}\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 = results.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
138
+ if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
139
+ return [...structured, ...plain, ...errors].join("\n") + ([...structured, ...plain, ...errors].length ? "\n" : "");
140
+ }
141
+
142
+ const versionParts = (value) => String(value ?? "").split(".").map((part) => Number.parseInt(part, 10));
143
+ const atLeast = (actual, minimum) => {
144
+ const a = versionParts(actual);
145
+ const b = versionParts(minimum);
146
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
147
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
148
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
149
+ }
150
+ return true;
151
+ };
152
+
153
+ /** The managed dispatcher deliberately fails open; doctor makes that visible. */
154
+ export async function managedHookCliStatus({ home = homedir(), read = readFile, accessImpl = access } = {}) {
155
+ let metadata;
156
+ try { metadata = JSON.parse(await read(join(managedHooksDir(home), "metadata.json"), "utf8")); }
157
+ catch (error) {
158
+ return { ok: false, reason: "metadata_missing", message: `managed hook metadata is unavailable (${error.code ?? error.message})` };
159
+ }
160
+ if (!isAbsolute(String(metadata.cliPath ?? ""))) return { ...metadata, ok: false, reason: "path_invalid", message: "managed hook nodii-dev path is not absolute" };
161
+ try { await accessImpl(metadata.cliPath, constants.X_OK); }
162
+ catch { return { ...metadata, ok: false, reason: "cli_missing", message: `managed hook CLI is missing or not executable: ${metadata.cliPath}` }; }
163
+ const minimum = metadata.minimumVersion ?? MANAGED_DISPATCH_MIN_VERSION;
164
+ if (!atLeast(metadata.cliVersion, minimum)) return { ...metadata, ok: false, reason: "cli_too_old", message: `managed hook CLI ${metadata.cliVersion ?? "unknown"} is older than ${minimum}` };
165
+ return { ...metadata, ok: true };
166
+ }
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();
@@ -370,12 +435,19 @@ export async function safetyPostHook(payload) {
370
435
  return { recorded: false };
371
436
  }
372
437
 
373
- /** Pin the exact commit and origin/main base Dev's review transition covers. */
438
+ /** The exact commit and origin/main base a review and its local receipt cover. */
439
+ export async function reviewCommitContext(cwd = process.cwd()) {
440
+ return {
441
+ commit: await git(cwd, "rev-parse", "HEAD"),
442
+ base: await git(cwd, "rev-parse", "origin/main"),
443
+ };
444
+ }
445
+
446
+ /** Pin the exact commit and origin/main base Dev accepted for review. */
374
447
  export async function recordReviewReceipt(cwd = process.cwd()) {
375
448
  const path = await gitPrivatePath(cwd, REVIEW_RECEIPT);
376
449
  const receipt = {
377
- commit: await git(cwd, "rev-parse", "HEAD"),
378
- base: await git(cwd, "rev-parse", "origin/main"),
450
+ ...(await reviewCommitContext(cwd)),
379
451
  recordedAt: new Date().toISOString(),
380
452
  };
381
453
  await mkdir(dirname(path), { recursive: true });
@@ -395,3 +467,16 @@ export const safetySettingsSnippet = () => ({
395
467
  PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
396
468
  },
397
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.24",
3
+ "version": "0.6.26",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {