@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.
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>`.";
package/dist/lanes.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
4
- import { recordReviewReceipt } from "./safety.js";
5
+ import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
  const via = { via: "nodii-dev" };
@@ -53,6 +54,19 @@ function evidence(flags) {
53
54
 
54
55
  const list = (value) => String(value).split(",").map((x) => x.trim()).filter(Boolean);
55
56
 
57
+ export async function reviewBody(flags, read = (path) => readFile(path, "utf8")) {
58
+ const file = requireFlag(flags, "file");
59
+ let body;
60
+ try {
61
+ body = JSON.parse(await read(file));
62
+ } catch (error) {
63
+ throw new CliError("REVIEW_FILE_INVALID", `Cannot read review JSON from ${file}: ${error.message}`, 2);
64
+ }
65
+ if (!body || typeof body !== "object" || Array.isArray(body))
66
+ throw new CliError("REVIEW_FILE_INVALID", "The review file must contain one JSON object.", 2);
67
+ return { ...body, ...via };
68
+ }
69
+
56
70
  /**
57
71
  * DEC-0011 (MW1-28.1): the body for `lane non-issue`. A re-route needs at
58
72
  * least one issue key or an explicit `none`, and the branch work is either
@@ -99,7 +113,7 @@ export async function branchCommits(lane) {
99
113
  }
100
114
  }
101
115
 
102
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, reviewReceipt = recordReviewReceipt } = {}) {
116
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt } = {}) {
103
117
  if (action === "list") {
104
118
  const c = flags.mine ? await config() : null;
105
119
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -170,12 +184,50 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
170
184
  }
171
185
  if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
172
186
  if (action === "review") {
173
- const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, via);
187
+ const body = await reviewBody(flags, read);
188
+ const current = await reviewContext();
189
+ if (body.head !== current.commit || body.base !== current.base)
190
+ throw new CliError(
191
+ "REVIEW_FILE_STALE",
192
+ `The review file covers base ${body.base ?? "(missing)"} and head ${body.head ?? "(missing)"}, but this worktree is base ${current.base} and head ${current.commit}. Review the current diff and write a new receipt.`,
193
+ 2,
194
+ );
195
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, body);
174
196
  try { await reviewReceipt(); }
175
197
  catch (error) {
176
- throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} from its worktree.`, 1);
198
+ throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} --file REVIEW.json from its worktree.`, 1);
177
199
  }
178
- return { payload, human: `${ref}: review recorded for the current commit and origin/main.` };
200
+ return {
201
+ payload,
202
+ human: (p) => {
203
+ const findings = p.data.findings ?? [];
204
+ const missed = findings.filter((finding) => finding.missedTrigger === true).length;
205
+ return `${ref}: review recorded for the current commit and origin/main (${p.data.exitReason}; ${findings.length} finding(s), ${missed} missed trigger(s)).`;
206
+ },
207
+ };
208
+ }
209
+ if (action === "reviews") {
210
+ const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
211
+ return {
212
+ payload,
213
+ human: (p) => {
214
+ const reviews = p.data.reviews ?? [];
215
+ return reviews.length
216
+ ? reviews
217
+ .map((run) => {
218
+ const missed = run.findings.filter((finding) => finding.missedTrigger === true).length;
219
+ return [
220
+ `${run.createdAt}\t${run.head}\t${run.exitReason}\t${run.findings.length} finding(s)\t${missed} missed trigger(s)`,
221
+ ...run.findings.map(
222
+ (finding) =>
223
+ ` ${finding.severity}\t${finding.perspective}\t${finding.clauseId ?? "-"}\t${finding.summary}${finding.missedTrigger ? "\tMISSED_TRIGGER" : ""}`,
224
+ ),
225
+ ].join("\n");
226
+ })
227
+ .join("\n")
228
+ : `No reviews recorded for ${ref}.`;
229
+ },
230
+ };
179
231
  }
180
232
  if (action === "verify") {
181
233
  const value = requireFlag(flags, "dev-check");