@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/README.md +46 -11
- package/dist/cli.js +62 -16
- package/dist/core.js +2 -2
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +20 -0
- package/dist/harness.js +114 -21
- package/dist/hook-doctor.js +126 -0
- package/dist/hooks.js +31 -1
- package/dist/lanes.js +57 -5
- package/dist/managed-hooks.js +166 -0
- package/dist/safety.js +112 -27
- package/dist/setup.js +18 -2
- package/package.json +1 -1
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 {
|
|
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
|
|
147
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
386
|
-
const fetchHarness = async () => {
|
|
387
|
-
const
|
|
388
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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");
|