@nodii/dev 0.6.25 → 0.6.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -10
- package/dist/cli.js +63 -24
- package/dist/core.js +2 -2
- 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/managed-hooks.js +169 -0
- package/dist/nodii-dev.js +13 -1
- package/dist/safety.js +102 -24
- package/dist/setup.js +18 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -351,19 +351,40 @@ nodii-dev docs upload PATH... --class ruling|conformance|editorial
|
|
|
351
351
|
nodii-dev docs ratify --domain KEY [--domain KEY ...] --decision DECISION.json [--wait]
|
|
352
352
|
nodii-dev docs parked [CODE] [--status pending|acknowledged]
|
|
353
353
|
|
|
354
|
-
nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run]
|
|
355
|
-
nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run]
|
|
354
|
+
nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
|
|
355
|
+
nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
|
|
356
356
|
nodii-dev harness check [--json] # 0 current, 1 differs from Dev, 2 no harness
|
|
357
357
|
nodii-dev harness update --check # the same check
|
|
358
358
|
```
|
|
359
359
|
|
|
360
|
-
`harness install` and `harness update` merge the
|
|
361
|
-
`.claude/settings.json`; every installed hook invokes
|
|
360
|
+
`harness install` and `harness update` merge the safety hooks into both
|
|
361
|
+
`.claude/settings.json` and `.codex/hooks.json`; every installed hook invokes
|
|
362
|
+
only `nodii-dev`. The
|
|
362
363
|
pre-hook blocks edits outside a lane's optional `ownedPaths`, generated UI/API
|
|
363
364
|
files, unsafe Git operations, stale or missing review receipts, lane identity
|
|
364
365
|
changes, production-secret/test-auth bypasses, cleanup of Docker resources the
|
|
365
366
|
run did not create, and deploy/apply commands without an exact grant. Allowed
|
|
366
|
-
calls produce no output.
|
|
367
|
+
calls produce no output. Codex receives runtime-native `Bash` and `apply_patch`
|
|
368
|
+
matchers, JSON denials, and `additionalContext` nudges. A compact session start
|
|
369
|
+
prints the current lane Freeze again.
|
|
370
|
+
|
|
371
|
+
Hard blocks apply only after Dev proves the current agent, branch and worktree
|
|
372
|
+
belong to an active lane. Interactive or otherwise unproven sessions receive
|
|
373
|
+
nudges instead. Push is blocked only when that proven lane also exposes the
|
|
374
|
+
review-receipt flow; until then the hook says it cannot verify the review and
|
|
375
|
+
allows the command.
|
|
376
|
+
|
|
377
|
+
Codex trusts system-managed hooks without a per-hash prompt. `--system` writes
|
|
378
|
+
the repository harness normally and refreshes the user-owned dispatcher under
|
|
379
|
+
`~/.config/nodii-dev/codex-managed-hooks`, then only prints the one-time
|
|
380
|
+
`sudo install` commands an operator can review and run to place its stable
|
|
381
|
+
policy in `/etc/codex/requirements.toml`; nodii-dev never runs sudo itself.
|
|
382
|
+
That system layer covers both `CODEX_HOME=~/.codex` and
|
|
383
|
+
`CODEX_HOME=~/.codex-work`; later `--system` updates need no sudo. The
|
|
384
|
+
dispatcher records the absolute executable path so hook processes never
|
|
385
|
+
depend on `PATH`, and refreshes it on every update. Outside a lane, or when
|
|
386
|
+
that executable is missing or too old, it fails open without diagnostics;
|
|
387
|
+
`doctor` reports that fail-open state for each Codex profile.
|
|
367
388
|
|
|
368
389
|
Until Dev exposes a lane's owned paths (AGENT-HARNESS-10), an edit with no
|
|
369
390
|
ownership field emits a nudge instead of claiming it is safe or blocking an
|
|
@@ -603,11 +624,14 @@ OAuth login.
|
|
|
603
624
|
`harness install` writes the repository's agent harness as Dev generates it
|
|
604
625
|
from the ratified methodology: the `CLAUDE.md` and `AGENTS.md` pointer
|
|
605
626
|
blocks, one pointer skill per methodology document, the Claude Code hooks
|
|
606
|
-
merged into `.claude/settings.json`,
|
|
627
|
+
merged into `.claude/settings.json`, their Codex equivalents merged into
|
|
628
|
+
`.codex/hooks.json`, and Dev's `.nodii/harness.json`. Every
|
|
607
629
|
generated file carries the methodology version it came from. Nothing about
|
|
608
630
|
the rules is authored locally. Run it in the service's own repository: it
|
|
609
|
-
installs one service at a time, and there is no fleet mode.
|
|
610
|
-
|
|
631
|
+
installs one service at a time, and there is no fleet mode. While no
|
|
632
|
+
methodology is ratified, install and update automatically retry with the
|
|
633
|
+
latest draft versions and print the exact draft methodology and source
|
|
634
|
+
versions they used. `--include-drafts` requests that behavior explicitly.
|
|
611
635
|
|
|
612
636
|
The CLI records what it wrote in `.nodii/harness-state.json` and never touches
|
|
613
637
|
anything else. An existing file it did not write is refused, unless the
|
|
@@ -655,6 +679,8 @@ exits 0 only when every check is `OK`. `--json` returns the same checks as
|
|
|
655
679
|
| `lane_limit` | The agent's active lanes against its lane limit. A full limit is `OK` when a lane on this branch holds the slot. |
|
|
656
680
|
| `figma_file` | Only with `--figma-file KEY` (a key or figma.com link), or `NODII_DEV_FIGMA_FILE`: whether Dev has the brief's Figma file registered. `MISSING` names the `figma file add` command, since `figma lock` would fail with `DESIGN_FILE_NOT_FOUND`. |
|
|
657
681
|
| `figma_scopes` | With `figma_file`, when the file is registered: the change set this agent holds in the file and its lock scopes (or the whole file), or that it holds none (DEC-0032). |
|
|
682
|
+
| `hooks_claude` | Whether Claude hooks are installed, trusted by that runtime, and have a private firing receipt. |
|
|
683
|
+
| `hooks_codex_personal`, `hooks_codex_work` | For `~/.codex` and `~/.codex-work`, whether Codex's own hook registry reports the hooks installed and trusted (including managed status), whether the managed absolute CLI is present and new enough, and whether that profile has fired one. |
|
|
658
684
|
|
|
659
685
|
Doctor may refresh the access token, as any command does. The permission table
|
|
660
686
|
is kept by hand from Dev's route policy until Dev's OpenAPI names each
|
|
@@ -671,9 +697,9 @@ serves for that step (see Rule delivery). Server refusals,
|
|
|
671
697
|
including operator-only actions, workflow blocks, lane limits and Built items
|
|
672
698
|
that are not on dev, retain Dev's error code/message and exit non-zero.
|
|
673
699
|
|
|
674
|
-
`setup` installs the Claude Code workflow
|
|
700
|
+
`setup` installs the Claude Code and Codex workflow harnesses in the current repository.
|
|
675
701
|
It writes `.claude/skills/nodii-build-workflow/SKILL.md`, merges SessionStart,
|
|
676
|
-
Stop and
|
|
702
|
+
Stop and tool hooks into `.claude/settings.json` and `.codex/hooks.json`, and maintains one
|
|
677
703
|
marked section in an existing `CLAUDE.md` or `AGENTS.md` (creating `CLAUDE.md`
|
|
678
704
|
when neither exists). Existing settings and hooks are preserved. The default
|
|
679
705
|
is a read-only preview; `--yes` applies, and `--check` is suitable for drift
|
package/dist/cli.js
CHANGED
|
@@ -20,11 +20,12 @@ import {
|
|
|
20
20
|
refuseFromLane,
|
|
21
21
|
revokeOAuth,
|
|
22
22
|
} from "./oauth.js";
|
|
23
|
-
import { heartbeatCurrentLane, postHook, postHookFailure, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
23
|
+
import { heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
24
24
|
import { setupCommand } from "./setup.js";
|
|
25
25
|
import { harnessCommand } from "./harness.js";
|
|
26
|
-
import { safetyPostHook, safetyPreHook } from "./safety.js";
|
|
26
|
+
import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
|
|
27
27
|
import { doctorCommand } from "./doctor.js";
|
|
28
|
+
import { dispatchManagedHook } from "./managed-hooks.js";
|
|
28
29
|
import {
|
|
29
30
|
figmaChangeSetShow,
|
|
30
31
|
figmaChangeSetText,
|
|
@@ -120,9 +121,10 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
120
121
|
figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
|
|
121
122
|
figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
|
|
122
123
|
# register a Figma file in Dev so it can be locked
|
|
123
|
-
hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|print-settings
|
|
124
|
+
hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
|
|
124
125
|
setup [--yes|--check] # dry-run by default
|
|
125
|
-
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run]
|
|
126
|
+
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
|
|
127
|
+
# --system stages managed hooks and only prints operator-reviewed sudo commands
|
|
126
128
|
harness check # 0 current, 1 differs from Dev, 2 not installed
|
|
127
129
|
harness update --check # the same check
|
|
128
130
|
docs check|validate|upload <path...> --class ruling|conformance|editorial
|
|
@@ -757,15 +759,28 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
757
759
|
if (command === "hook") {
|
|
758
760
|
if (action === "print-settings")
|
|
759
761
|
return console.log(JSON.stringify(settingsSnippet(), null, 2));
|
|
760
|
-
if (action === "
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
762
|
+
if (action === "managed-dispatch") {
|
|
763
|
+
if (!rest[0] || rest.length > 1) throw new CliError("USAGE", "hook managed-dispatch needs one event name", 2);
|
|
764
|
+
const output = await dispatchManagedHook(rest[0], await stdinText());
|
|
765
|
+
if (output) process.stdout.write(output);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (["claude-figma-pre", "figma-pre"].includes(action)) {
|
|
769
|
+
let payload;
|
|
765
770
|
try {
|
|
766
|
-
|
|
767
|
-
|
|
771
|
+
payload = JSON.parse(await stdinText());
|
|
772
|
+
await recordHookFire(payload).catch(() => {});
|
|
773
|
+
const result = await preHook(payload);
|
|
774
|
+
if (hookRuntime(payload) !== "codex") return emit(result, json, "allowed");
|
|
775
|
+
return;
|
|
768
776
|
} catch (error) {
|
|
777
|
+
// A known policy denial still blocks. Hook/runtime failures fail open
|
|
778
|
+
// and stay silent so a broken environment cannot stop the session.
|
|
779
|
+
if (!["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED"].includes(error?.code)) return;
|
|
780
|
+
if (payload && hookRuntime(payload) === "codex") {
|
|
781
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Blocked Figma write: ${error?.message ?? String(error)}` } }));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
769
784
|
throw new CliError(
|
|
770
785
|
error?.code ?? "FIGMA_HOOK_FAILED",
|
|
771
786
|
`Blocked Figma write: ${error?.message ?? String(error)}`,
|
|
@@ -775,12 +790,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
775
790
|
}
|
|
776
791
|
}
|
|
777
792
|
if (action === "safety-pre") {
|
|
778
|
-
|
|
793
|
+
let payload;
|
|
779
794
|
try {
|
|
780
|
-
|
|
781
|
-
|
|
795
|
+
payload = JSON.parse(await stdinText());
|
|
796
|
+
await recordHookFire(payload).catch(() => {});
|
|
797
|
+
const result = await safetyPreHook(payload);
|
|
798
|
+
const nudges = result.nudges ?? [];
|
|
799
|
+
if (nudges.length && hookRuntime(payload) === "codex")
|
|
800
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
|
|
801
|
+
else for (const nudge of nudges) console.log(nudge);
|
|
782
802
|
return;
|
|
783
803
|
} catch (error) {
|
|
804
|
+
// Only an evaluated safety rule may deny. Discovery, parsing, git,
|
|
805
|
+
// config and Dev errors fail open and produce no output.
|
|
806
|
+
if (!String(error?.code ?? "").startsWith("SAFETY_")) return;
|
|
807
|
+
if (payload && hookRuntime(payload) === "codex") {
|
|
808
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: error?.message ?? String(error) } }));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
784
811
|
throw new CliError(
|
|
785
812
|
error?.code ?? "SAFETY_HOOK_FAILED",
|
|
786
813
|
error?.message ?? String(error),
|
|
@@ -790,13 +817,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
790
817
|
}
|
|
791
818
|
}
|
|
792
819
|
if (action === "safety-post") {
|
|
820
|
+
let payload;
|
|
793
821
|
try {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
console.error(`nodii-dev safety post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`);
|
|
799
|
-
}
|
|
822
|
+
payload = JSON.parse(await stdinText());
|
|
823
|
+
await recordHookFire(payload).catch(() => {});
|
|
824
|
+
await safetyPostHook(payload);
|
|
825
|
+
} catch {}
|
|
800
826
|
return;
|
|
801
827
|
}
|
|
802
828
|
if (action === "lane-heartbeat") {
|
|
@@ -804,18 +830,31 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
804
830
|
return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
|
|
805
831
|
}
|
|
806
832
|
const payload = JSON.parse(await stdinText());
|
|
833
|
+
await recordHookFire(payload).catch(() => {});
|
|
834
|
+
if (action === "session-start") {
|
|
835
|
+
const context = await sessionStartContext(payload);
|
|
836
|
+
if (context) console.log(context);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
807
839
|
if (action === "workflow-post") {
|
|
808
840
|
const suggestion = workflowSuggestion(payload);
|
|
809
|
-
if (suggestion
|
|
841
|
+
if (suggestion && hookRuntime(payload) === "codex")
|
|
842
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: suggestion } }));
|
|
843
|
+
else if (suggestion) console.log(suggestion);
|
|
810
844
|
return;
|
|
811
845
|
}
|
|
812
|
-
if (
|
|
846
|
+
if (["claude-figma-post", "figma-post"].includes(action)) {
|
|
813
847
|
try {
|
|
814
848
|
return emit(await postHook(payload), json, "logged");
|
|
815
849
|
} catch (error) {
|
|
816
850
|
const outcome = postHookFailure(error);
|
|
817
|
-
|
|
818
|
-
if (
|
|
851
|
+
if (!outcome.exitCode) return;
|
|
852
|
+
if (hookRuntime(payload) === "codex")
|
|
853
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: outcome.message } }));
|
|
854
|
+
else {
|
|
855
|
+
console.error(outcome.message);
|
|
856
|
+
if (outcome.exitCode) process.exitCode = outcome.exitCode;
|
|
857
|
+
}
|
|
819
858
|
return;
|
|
820
859
|
}
|
|
821
860
|
}
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.27";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
|
@@ -199,7 +199,7 @@ export async function checkLogin() {
|
|
|
199
199
|
export const encode = (value) => encodeURIComponent(value);
|
|
200
200
|
// Switches never take the next token as their value, so
|
|
201
201
|
// `docs upload --wait rulebook/` keeps rulebook/ as a path.
|
|
202
|
-
const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force"]);
|
|
202
|
+
const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system"]);
|
|
203
203
|
export function parseArgs(argv) {
|
|
204
204
|
const flags = {};
|
|
205
205
|
const args = [];
|
package/dist/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./cor
|
|
|
5
5
|
import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
|
|
6
6
|
import { inferGitContext, repoName } from "./lanes.js";
|
|
7
7
|
import { figmaFileKey, figmaLockCheck, holdsSet, notRegisteredHint, scopeText } from "./figma.js";
|
|
8
|
+
import { hookDiagnostics } from "./hook-doctor.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* What a lane's commands need, in Dev's route policy (staffRuleFor in Dev's
|
|
@@ -262,6 +263,25 @@ export async function doctorCommand(deps = {}) {
|
|
|
262
263
|
}
|
|
263
264
|
}
|
|
264
265
|
|
|
266
|
+
// 9. Runtime hooks: present, accepted by that runtime, and observed firing.
|
|
267
|
+
const hookRoot = context?.worktree ?? deps.cwd ?? process.cwd();
|
|
268
|
+
try {
|
|
269
|
+
const runtimes = await (deps.hookDiagnostics ?? hookDiagnostics)(hookRoot);
|
|
270
|
+
for (const runtime of runtimes) {
|
|
271
|
+
const key = `hooks_${runtime.runtime.replace(/[^a-z0-9]+/gi, "_")}`;
|
|
272
|
+
const extra = { runtime: runtime.runtime, installed: runtime.installed, trusted: runtime.trusted, firing: runtime.firing, managed: runtime.managed, lastFiredAt: runtime.lastFiredAt ?? null, ...(runtime.cliStatus ? { cliStatus: runtime.cliStatus } : {}) };
|
|
273
|
+
if (runtime.installed === null) checks.push(cannot(key, runtime.error ?? "runtime hook registry unavailable", extra));
|
|
274
|
+
else if (!runtime.installed) checks.push(missing(key, "hooks are not installed", "nodii-dev harness install (or setup --yes)", extra));
|
|
275
|
+
else if (!runtime.trusted) checks.push(missing(key, `hooks are installed but not trusted${runtime.trustStatuses?.length ? ` (${runtime.trustStatuses.join(", ")})` : ""}`, runtime.runtime.startsWith("codex") ? "review /hooks, or have an operator run the commands printed by `nodii-dev harness update --system`" : "review the runtime hook configuration", extra));
|
|
276
|
+
else if (runtime.managed && runtime.cliStatus?.ok === false) checks.push(missing(key, `${runtime.cliStatus.message}; managed hooks fail open until this is fixed`, "update nodii-dev, then run `nodii-dev harness update --system` to refresh the absolute CLI path", extra));
|
|
277
|
+
else if (!runtime.firing) checks.push(missing(key, `hooks are installed and trusted${runtime.managed ? " (managed)" : ""}, but no firing receipt exists`, "start a fresh runtime session and run doctor again", extra));
|
|
278
|
+
else checks.push(ok(key, `installed, trusted${runtime.managed ? " (managed)" : ""}, and firing (last ${runtime.lastFiredAt})`, extra));
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
checks.push(cannot("hooks_claude", error?.message ?? String(error)));
|
|
282
|
+
checks.push(cannot("hooks_codex", error?.message ?? String(error)));
|
|
283
|
+
}
|
|
284
|
+
|
|
265
285
|
const ready = checks.every((x) => x.status === "ok");
|
|
266
286
|
const width = Math.max(...checks.map((x) => x.key.length));
|
|
267
287
|
const human = (p) => [
|
package/dist/harness.js
CHANGED
|
@@ -7,9 +7,11 @@ import { execFile } from "node:child_process";
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
9
|
import { dirname, join, normalize, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { promisify } from "node:util";
|
|
11
12
|
import { CliError, devRequest, query } from "./core.js";
|
|
12
|
-
import {
|
|
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>`.";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
6
|
+
import { CliError, VERSION } from "./core.js";
|
|
7
|
+
|
|
8
|
+
export const CODEX_HOOK_EVENTS = [
|
|
9
|
+
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse",
|
|
10
|
+
"PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop", "Interrupt", "SessionEnd",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const managedHooksDir = (home = homedir()) => join(home, ".config", "nodii-dev", "codex-managed-hooks");
|
|
14
|
+
export const MANAGED_DISPATCH_MIN_VERSION = "0.6.27";
|
|
15
|
+
|
|
16
|
+
/** Only a single nodii-dev invocation is allowed; shell composition is never accepted. */
|
|
17
|
+
export function nodiiCommandArgs(command) {
|
|
18
|
+
const text = String(command ?? "").trim();
|
|
19
|
+
if (!text.startsWith("nodii-dev") || /[;&|<>`$\\\r\n]/.test(text))
|
|
20
|
+
throw new CliError("HARNESS_INVALID", `Managed hooks may run one nodii-dev command only: ${command}`, 1);
|
|
21
|
+
const words = text.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
22
|
+
if (words.shift() !== "nodii-dev") throw new CliError("HARNESS_INVALID", `Managed hooks may run nodii-dev commands only: ${command}`, 1);
|
|
23
|
+
return words.map((word) => /^(['"]).*\1$/.test(word) ? word.slice(1, -1) : word);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateManagedHooks(hooks = {}) {
|
|
27
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
28
|
+
if (!CODEX_HOOK_EVENTS.includes(event)) throw new CliError("HARNESS_INVALID", `Codex does not support managed hook event ${event}.`, 1);
|
|
29
|
+
for (const entry of entries)
|
|
30
|
+
for (const handler of entry.hooks ?? []) {
|
|
31
|
+
if (handler.type !== "command") throw new CliError("HARNESS_INVALID", `Managed ${event} hooks must be command handlers.`, 1);
|
|
32
|
+
nodiiCommandArgs(handler.command);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tomlString = (value) => JSON.stringify(String(value));
|
|
38
|
+
const shellString = (value) => `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
39
|
+
const laneConfig = (env) => /^lane-[a-z0-9_-]+\.json$/i.test(basename(env.NODII_DEV_CONFIG_PATH ?? ""));
|
|
40
|
+
const emptyResponse = (event) => ["Stop", "SubagentStop"].includes(event) ? "{}\n" : "";
|
|
41
|
+
|
|
42
|
+
/** Stable managed policy: all later hook changes live in user-owned hooks.json. */
|
|
43
|
+
export function managedRequirements(dir) {
|
|
44
|
+
const dispatcher = join(dir, "dispatch");
|
|
45
|
+
return [
|
|
46
|
+
"[features]",
|
|
47
|
+
"hooks = true",
|
|
48
|
+
"",
|
|
49
|
+
"[hooks]",
|
|
50
|
+
`managed_dir = ${tomlString(dir)}`,
|
|
51
|
+
"",
|
|
52
|
+
...CODEX_HOOK_EVENTS.flatMap((event) => [
|
|
53
|
+
`[[hooks.${event}]]`,
|
|
54
|
+
'matcher = ".*"',
|
|
55
|
+
"",
|
|
56
|
+
`[[hooks.${event}.hooks]]`,
|
|
57
|
+
'type = "command"',
|
|
58
|
+
`command = ${tomlString(`${dispatcher} ${event}`)}`,
|
|
59
|
+
"timeout = 30",
|
|
60
|
+
'statusMessage = "Applying Nodii harness policy"',
|
|
61
|
+
"",
|
|
62
|
+
]),
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function managedHookBundle(hooksText, { home, cliPath, cliVersion = VERSION } = {}) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try { parsed = JSON.parse(String(hooksText ?? "")); }
|
|
69
|
+
catch { throw new CliError("HARNESS_SETTINGS_INVALID", ".codex/hooks.json is not valid JSON.", 2); }
|
|
70
|
+
validateManagedHooks(parsed.hooks ?? {});
|
|
71
|
+
if (!isAbsolute(String(cliPath ?? ""))) throw new CliError("HARNESS_INVALID", "The managed Codex dispatcher needs the absolute nodii-dev executable path.", 1);
|
|
72
|
+
const dir = managedHooksDir(home);
|
|
73
|
+
const command = shellString(cliPath);
|
|
74
|
+
const dispatch = `#!/bin/sh\nmanaged_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nmanaged_cli=${command}\nempty_response() {\n case "$1" in Stop|SubagentStop) printf '{}\\n' ;; esac\n}\ncase "\${NODII_DEV_CONFIG_PATH##*/}" in lane-*.json) ;; *) empty_response "$1"; exit 0 ;; esac\nif [ ! -x "$managed_cli" ]; then empty_response "$1"; exit 0; fi\nexport NODII_DEV_MANAGED_HOOKS_DIR="$managed_dir"\nexport NODII_DEV_MANAGED_CLI_PATH="$managed_cli"\nmanaged_output=$("$managed_cli" hook managed-dispatch "$1" 2>/dev/null)\nmanaged_status=$?\nif [ "$managed_status" -ne 0 ]; then empty_response "$1"; exit 0; fi\nif [ -n "$managed_output" ]; then printf '%s\\n' "$managed_output"; else empty_response "$1"; fi\n`;
|
|
75
|
+
const metadata = `${JSON.stringify({ cliPath, cliVersion, minimumVersion: MANAGED_DISPATCH_MIN_VERSION }, null, 2)}\n`;
|
|
76
|
+
return { dir, hooksText: `${JSON.stringify({ hooks: parsed.hooks ?? {} }, null, 2)}\n`, dispatch, metadata, requirements: managedRequirements(dir) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeManagedHookBundle(bundle) {
|
|
80
|
+
await mkdir(bundle.dir, { recursive: true, mode: 0o700 });
|
|
81
|
+
await writeFile(join(bundle.dir, "hooks.json"), bundle.hooksText, { mode: 0o600 });
|
|
82
|
+
await writeFile(join(bundle.dir, "dispatch"), bundle.dispatch, { mode: 0o700 });
|
|
83
|
+
await chmod(join(bundle.dir, "dispatch"), 0o700);
|
|
84
|
+
await writeFile(join(bundle.dir, "metadata.json"), bundle.metadata, { mode: 0o600 });
|
|
85
|
+
await writeFile(join(bundle.dir, "requirements.toml"), bundle.requirements, { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runHandler(args, input, { spawnImpl = spawn, env = process.env } = {}) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const child = spawnImpl(env.NODII_DEV_MANAGED_CLI_PATH ?? "nodii-dev", args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
91
|
+
let stdout = "";
|
|
92
|
+
let stderr = "";
|
|
93
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
94
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
95
|
+
child.on("error", (error) => resolve({ status: 1, stdout, stderr: `${stderr}${error.message}` }));
|
|
96
|
+
child.on("exit", (status) => resolve({ status: status ?? 1, stdout, stderr }));
|
|
97
|
+
child.stdin.end(input);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const matchTarget = (event, payload) => {
|
|
102
|
+
if (["PreToolUse", "PermissionRequest", "PostToolUse"].includes(event)) return payload.tool_name ?? "";
|
|
103
|
+
if (event === "SessionStart") return payload.source ?? "";
|
|
104
|
+
return payload.agent_type ?? payload.source ?? "";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function responseOf(stdout) {
|
|
108
|
+
try { return JSON.parse(stdout.trim()); } catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Execute the current generated hook set behind the stable managed dispatcher. */
|
|
112
|
+
export async function dispatchManagedHook(event, payloadText, deps = {}) {
|
|
113
|
+
try {
|
|
114
|
+
if (!CODEX_HOOK_EVENTS.includes(event)) return emptyResponse(event);
|
|
115
|
+
const env = deps.env ?? process.env;
|
|
116
|
+
if (!laneConfig(env)) return emptyResponse(event);
|
|
117
|
+
const dir = deps.dir ?? env.NODII_DEV_MANAGED_HOOKS_DIR ?? managedHooksDir();
|
|
118
|
+
const config = JSON.parse(await (deps.read ?? readFile)(join(dir, "hooks.json"), "utf8"));
|
|
119
|
+
validateManagedHooks(config.hooks ?? {});
|
|
120
|
+
const payload = JSON.parse(payloadText);
|
|
121
|
+
const target = matchTarget(event, payload);
|
|
122
|
+
const handlers = (config.hooks?.[event] ?? []).filter((entry) => {
|
|
123
|
+
try { return new RegExp(entry.matcher || ".*").test(target); } catch { return false; }
|
|
124
|
+
}).flatMap((entry) => entry.hooks ?? []);
|
|
125
|
+
const results = await Promise.all(handlers.map((handler) => (deps.run ?? runHandler)(nodiiCommandArgs(handler.command), payloadText, deps)));
|
|
126
|
+
// A failed handler is unavailable policy, not a denial. Ignore both its
|
|
127
|
+
// output and stderr; doctor is where hook health remains visible.
|
|
128
|
+
const successful = results.filter((result) => result.status === 0);
|
|
129
|
+
const messages = successful.map((result) => responseOf(result.stdout)).filter(Boolean);
|
|
130
|
+
if (["PreToolUse", "PermissionRequest"].includes(event)) {
|
|
131
|
+
const denied = messages.find((message) => message.hookSpecificOutput?.permissionDecision === "deny");
|
|
132
|
+
if (denied) return `${JSON.stringify(denied)}\n`;
|
|
133
|
+
const contexts = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
|
|
134
|
+
return contexts.length ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: contexts.join("\n") } })}\n` : "";
|
|
135
|
+
}
|
|
136
|
+
const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
|
|
137
|
+
const plain = successful.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
|
|
138
|
+
if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
|
|
139
|
+
return [...structured, ...plain].join("\n") + ([...structured, ...plain].length ? "\n" : "");
|
|
140
|
+
} catch {
|
|
141
|
+
return emptyResponse(event);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const versionParts = (value) => String(value ?? "").split(".").map((part) => Number.parseInt(part, 10));
|
|
146
|
+
const atLeast = (actual, minimum) => {
|
|
147
|
+
const a = versionParts(actual);
|
|
148
|
+
const b = versionParts(minimum);
|
|
149
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
|
|
150
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
151
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** The managed dispatcher deliberately fails open; doctor makes that visible. */
|
|
157
|
+
export async function managedHookCliStatus({ home = homedir(), read = readFile, accessImpl = access } = {}) {
|
|
158
|
+
let metadata;
|
|
159
|
+
try { metadata = JSON.parse(await read(join(managedHooksDir(home), "metadata.json"), "utf8")); }
|
|
160
|
+
catch (error) {
|
|
161
|
+
return { ok: false, reason: "metadata_missing", message: `managed hook metadata is unavailable (${error.code ?? error.message})` };
|
|
162
|
+
}
|
|
163
|
+
if (!isAbsolute(String(metadata.cliPath ?? ""))) return { ...metadata, ok: false, reason: "path_invalid", message: "managed hook nodii-dev path is not absolute" };
|
|
164
|
+
try { await accessImpl(metadata.cliPath, constants.X_OK); }
|
|
165
|
+
catch { return { ...metadata, ok: false, reason: "cli_missing", message: `managed hook CLI is missing or not executable: ${metadata.cliPath}` }; }
|
|
166
|
+
const minimum = metadata.minimumVersion ?? MANAGED_DISPATCH_MIN_VERSION;
|
|
167
|
+
if (!atLeast(metadata.cliVersion, minimum)) return { ...metadata, ok: false, reason: "cli_too_old", message: `managed hook CLI ${metadata.cliVersion ?? "unknown"} is older than ${minimum}` };
|
|
168
|
+
return { ...metadata, ok: true };
|
|
169
|
+
}
|
package/dist/nodii-dev.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fail, main } from "./cli.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Hooks run in the agent's environment, which may be incomplete or may not be
|
|
5
|
+
// a git worktree. An unavailable hook must never turn into a refused tool call.
|
|
6
|
+
// Intentional policy denials are handled inside the hook command before they
|
|
7
|
+
// reach this boundary.
|
|
8
|
+
main().catch((error) => {
|
|
9
|
+
const denial = String(error?.code ?? "").startsWith("SAFETY_") ||
|
|
10
|
+
["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "WRITE_OUT_OF_SCOPE"].includes(error?.code);
|
|
11
|
+
if (process.argv[2] === "hook" && !denial) {
|
|
12
|
+
process.exitCode = 0;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
fail(error);
|
|
16
|
+
});
|
package/dist/safety.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
6
6
|
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
|
|
9
|
-
const
|
|
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
|
|
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")
|
|
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 (
|
|
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
|
|
212
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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)
|
|
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:
|
|
331
|
-
|
|
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
|
|
419
|
+
if (!SHELL_TOOLS.has(payload.tool_name) || !successful(payload)) return { recorded: false };
|
|
355
420
|
const command = String(payload.tool_input?.command ?? "");
|
|
356
421
|
const tokens = words(command);
|
|
357
422
|
const cwd = payload.cwd ?? process.cwd();
|
|
@@ -402,3 +467,16 @@ export const safetySettingsSnippet = () => ({
|
|
|
402
467
|
PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
|
|
403
468
|
},
|
|
404
469
|
});
|
|
470
|
+
|
|
471
|
+
export const codexSafetyHooksSnippet = () => ({
|
|
472
|
+
hooks: {
|
|
473
|
+
PreToolUse: [{ matcher: "^(Bash|apply_patch)$", hooks: [{ type: "command", command: "nodii-dev hook safety-pre" }] }],
|
|
474
|
+
PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
export const regroundingHooksSnippet = () => ({
|
|
479
|
+
hooks: {
|
|
480
|
+
SessionStart: [{ matcher: "^compact$", hooks: [{ type: "command", command: "nodii-dev hook session-start" }] }],
|
|
481
|
+
},
|
|
482
|
+
});
|
package/dist/setup.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
|
-
import {
|
|
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: [
|
|
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 = [];
|