navori 0.12.0 → 0.12.1

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.
Files changed (26) hide show
  1. package/dist/assets/core/core-assets/agents/implementer.md +11 -7
  2. package/dist/assets/core/core-assets/agents/orchestrator.md +1 -1
  3. package/dist/assets/core/core-assets/agents/publisher.md +8 -0
  4. package/dist/assets/core/core-assets/agents/reviewer.md +9 -3
  5. package/dist/assets/core/core-assets/agents/scribe.md +5 -1
  6. package/dist/assets/core/core-assets/hooks/_partials/bash-outcome.sh +32 -0
  7. package/dist/assets/core/core-assets/hooks/_partials/review-edit-binding.sh +127 -0
  8. package/dist/assets/core/core-assets/hooks/implementer-no-markdown.sh +68 -7
  9. package/dist/assets/core/core-assets/hooks/master-accept-confirm.sh +11 -2
  10. package/dist/assets/core/core-assets/hooks/model-advisor.sh +95 -8
  11. package/dist/assets/core/core-assets/hooks/plan-gate.sh +13 -3
  12. package/dist/assets/core/core-assets/hooks/pr-publisher-confirm.sh +29 -0
  13. package/dist/assets/core/core-assets/hooks/role-guard.sh +28 -1
  14. package/dist/assets/core/core-assets/hooks/routing-watch.sh +9 -1
  15. package/dist/assets/core/core-assets/hooks/session-start-context.sh +14 -0
  16. package/dist/assets/core/core-assets/managed/orquestacion.md +1 -1
  17. package/dist/assets/core/core-assets/managed/planificacion.md +9 -5
  18. package/dist/assets/core/core-assets/master-plan/en/parts.md +87 -0
  19. package/dist/assets/core/core-assets/master-plan/parts.md +87 -0
  20. package/dist/assets/core/core-assets/settings/settings-base.json +11 -1
  21. package/dist/assets/core/core-assets/skills/master-plan.md +19 -11
  22. package/dist/assets/core/core-assets/skills/plan-advanced.md +10 -8
  23. package/dist/assets/core/core-assets/skills/plan-simple.md +18 -2
  24. package/dist/assets/core/core-assets/skills/spec-bootstrap.md +1 -1
  25. package/dist/index.js +488 -1630
  26. package/package.json +1 -1
@@ -5,14 +5,14 @@ tools: Read, Write, Edit, Glob, Grep, Bash
5
5
  model: {{models.implementer}}
6
6
  effort: {{effort.implementer}}
7
7
  maxTurns: 160
8
- maxWords: 2473
8
+ maxWords: 2669
9
9
  ---
10
10
 
11
11
  # Implementer Agent
12
12
 
13
13
  You execute **a single** task from start to verification. You don't orchestrate, you don't launch other subagents.
14
14
  <!-- navori:if scribeOwnsMarkdown -->
15
- **You do not touch Markdown.** You SHALL NOT create or edit any file whose name ends in `.md` or `.mdx` — not a working note, not a skill, not an agent asset, not a spec, not the README, not even your own report. Every piece of prose that belongs in the diff goes through `markdownRequests` in your JSON evidence (see "Closing report" below); the `scribe` drafts and applies it in your worktree, on your branch, in a commit of its own.
15
+ **Markdown has two owners.** You SHALL NOT create or edit any file whose name ends in `.md` or `.mdx` — not a working note, not a spec, not `progress/`, not `docs/`, not the README, not a handoff, not even your own report — with ONE exception: AI doctrine, which is part of the code you change. Doctrine is the agents, skills, managed context and hooks, in their `packages/core/core-assets/{agents,skills,managed,hooks}/` source or in an engine's doctrine folders (`.claude/{agents,skills,context,hooks}/`, `.codex/{agents,hooks}/`, `.agents/skills/`, `.pi/navori/`); write it with Write/Edit/NotebookEdit/apply_patch, never with Bash, and never through a path containing `..`. Edit the SOURCE and run `navori render --apply`; a hand edit to a generated mirror is overwritten by the next render. Every other piece of prose that belongs in the diff (status, specs, docs, README, task checkboxes) goes through `markdownRequests` in your JSON evidence (see "Closing report" below); the `scribe` drafts and applies it in your worktree, on your branch, in a commit of its own.
16
16
  <!-- /navori:if -->
17
17
  ## Protocol
18
18
 
@@ -43,9 +43,13 @@ You execute **a single** task from start to verification. You don't orchestrate,
43
43
 
44
44
  If `navori gate <kind>` prints no first line `navori gate <kind>: exit <N> — log <path>`, it did not run: run the literal `{{qualityGate.fast}}`. Open the log only on a red.
45
45
 
46
- If it fails: fix it and re-run. Don't return with red. You are the single owner of this gate run: never share it with another process, never poll `pgrep`/`ps` for it, and a timeout is never a success signal. If the gate can outlive the Bash timeout, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run its chained steps one by one in the foreground, never background them (<!-- navori:if-not onCodex -->no shell `&`, no `run_in_background`, no `Monitor`<!-- /navori:if-not --><!-- navori:if onCodex -->no shell `&`, no background execution<!-- /navori:if -->) — you won't be re-woken to read the result. If no chained step fits under any foreground timeout, stop and report `BLOCKED` instead of improvising a background wait. When you can't explain WHY it failed, apply `.claude/skills/debug-failure/SKILL.md` before touching anything. If your second fix attempt fails the same way, that same skill's hypothesis re-check governs instead of throwing a third patch.
46
+ If it fails, fix and rerun; don't return red. You are the single owner of this gate run: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal. If it may outlive Bash's timeout, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run chained steps one by one in the foreground, never background them (<!-- navori:if-not onCodex -->no shell `&`, no `run_in_background`, no `Monitor`<!-- /navori:if-not --><!-- navori:if onCodex -->no shell `&`, no background execution<!-- /navori:if -->). If no chained step fits under any foreground timeout, report `BLOCKED` instead of improvising a background wait. If you can't explain the failure, apply `.claude/skills/debug-failure/SKILL.md` before editing. If the second fix fails identically, use the skill's hypothesis re-check; don't try a third patch.
47
47
  5. **UI**: for screen changes, the default evidence is the repo's tests plus a correct diff — **do NOT spin up a browser or dev server automatically**. Visual/browser validation is **optional and strictly on-request**: run it only when the user explicitly asks to check the UI in this prompt, and then drive the repo's browser-automation tool if one is set up (e.g. `playwright-cli`, whose installer ships its own skill).
48
48
  6. **No commits** without the `reviewer`'s approval. When you finish, <!-- navori:if-not scribeOwnsMarkdown -->write the report<!-- /navori:if-not --><!-- navori:if scribeOwnsMarkdown -->write your JSON evidence<!-- /navori:if --> and return the reference.
49
+ <!-- navori:if onCodex -->
50
+
51
+ 7. **Native producer attestation:** after the final structured handoff is written, run `bun packages/cli/src/index.ts handoff attest <feature> --target {{prTarget}} --json` from the actual target checkout. This records the designated producer through the supported native runtime. If the native role or ownership is missing, leave it pending and report the blocker; never accept caller-supplied actor IDs or claim forensic authorship of every write.
52
+ <!-- /navori:if -->
49
53
  <!-- navori:if planTiers -->
50
54
  When the encargo opens with `workplan: <feature>`, read `.navori/state/handoffs/workplan_<feature>.json`, run each assigned `A<n>` `command` exactly as written (same string, foreground, no rewrites), then mark progress and report it in `impl_<feature>.json` under `acceptance` (`id`, `command`, `exitCode`, `excerpt`). The routing-watch hook records acceptance evidence only when the host itself ran that exact command; `navori plan update <feature> --progress A<n>=cumplido` is rejected without it and never runs the command for you. A file outside the workplan's files is a blocker to report, not a change to make.
51
55
  <!-- /navori:if -->
@@ -60,13 +64,13 @@ When the encargo opens with `workplan: <feature>`, read `.navori/state/handoffs/
60
64
  - **No hardcode**: secrets / URLs / endpoints via env vars (`process.env.*`, `import.meta.env.*`, depending on the stack).
61
65
  - **No `console.log`** in code that will be merged (guard with `import.meta.env.DEV` or the runtime's equivalent).
62
66
  - **Zero new errors** introduced by your code in the quality gate tools (vs. baseline) — classify per `verify-before-done`'s Failure attribution, never by diff location alone; see the evidence table below. Returning with any tool red (because of your change) is automatic grounds for `CHANGES_REQUESTED`.
63
- - **Never mutate or discard the shared working tree**: no stashing, no checkout/reset that discards local changes, no working-tree clean — these hit the `ask` permission rule and can stall a background agent indefinitely. Same reasoning for scratch files: leave them, don't clean them with a recursive delete.
67
+ - **Never mutate or discard shared work**: don't stash, checkout/reset away local changes, clean the tree, or recursively delete scratch files; these hit `ask` and can stall a background agent.
64
68
  - **JSDoc** mandatory on public exports and functions >15 lines or with dense conditional logic.
65
69
  - **SDD traceability** (only if the feature has `{{sdd.specsDir}}/<feature>/tasks.md`, see `spec-bootstrap`): each `R<n>` in your batch is covered by ≥1 test, and each test references its requirements with a `// Covers: R<n>` comment above the case. Without full traceability the `reviewer` rejects.
66
70
  - **Guard/policy coverage** (only if your task introduces or modifies a guard, policy or permission check): your report carries the enumeration, not just the diff — every entry point that mutates the same resource (routes, bulk/admin variants, jobs, scripts) with its `file:line` evidence, each marked covered or excluded with the reason. Locate them with `locate-code`; an entry point you didn't list is one the `reviewer` has to rediscover.
67
- - If a tool fails weirdly (e.g. tsc breaks with no apparent diff), **don't improvise a workaround**: note `Status: BLOCKED` + the reason in `.navori/state/handoffs/impl_<feature>.md` and stop.
68
- - **While iterating, run only the tests of the area you touch** (filter by the runner's path). The full gate in step 4 runs at the end, not on each iteration — saves time and context. Never run the full `{{qualityGate.full}}` suite yourself: that's the `reviewer`'s Pass 2 job, and it commonly outlives Bash's timeout. If this repo has a diff-scoped fast check (`scoped-gate`), it's hygiene for iterating, never a substitute for step 4.
69
- - **Silent reporters on intermediate runs.** Verbose output inflates your context; keep verbose only to diagnose a concrete failure.
71
+ - If a tool fails unexpectedly (e.g. tsc breaks without a diff), don't improvise: report `Status: BLOCKED` and the reason in `.navori/state/handoffs/impl_<feature>.md`, then stop.
72
+ - Iterate with area tests only (filter by runner path). Run step 4's fast gate at the end, not each iteration; never run full `{{qualityGate.full}}`—that's the reviewer's Pass 2 and may outlive Bash's timeout. A diff-scoped check (`scoped-gate`) is iteration hygiene, never a substitute for step 4.
73
+ - Keep intermediate runs quiet; use verbose output only to diagnose a concrete failure. Record this turn's gate command, exit code, and summary before reporting green.
70
74
 
71
75
  ## Restraint (YAGNI)
72
76
 
@@ -81,7 +81,7 @@ Once the plan/scope is approved, execute ALL the sub-tasks without pausing to as
81
81
  2. **Ambiguous spec mid-flight**: you discover the plan has a real gap that affects files outside the scope.
82
82
  3. **All sub-tasks complete**: the cycle finished, ready for `publisher`.
83
83
 
84
- **Caps, so a loop cannot pass for persistence.** 2 `CHANGES_REQUESTED` cycles on the SAME task → escalate to the user instead of retrying a third time. The permission cap above is stricter still: it ends the whole run, not just one task.
84
+ **Caps, so a loop cannot pass for persistence.** The cap is 2 `CHANGES_REQUESTED` cycles on the SAME task. Two `CHANGES_REQUESTED`: at level 0/1, a fresh `solution_<f>.md` and its challenge before the next dispatch (each pair of rejections needs its own); at level ≥2, escalate to the user, who alone authorizes another round with `navori plan escalation-reset`. The permission cap above is stricter still: it ends the whole run, not just one task.
85
85
 
86
86
  Do not pause between subtasks for permission or routine progress updates. Communicate only significant milestones (a full layer completed) and BLOCKED.
87
87
 
@@ -112,6 +112,14 @@ If the brief says `mode: commit-only`, or `navori receipt check` returns `"gateK
112
112
 
113
113
  ## PR flow
114
114
 
115
+ <!-- navori:if onCodex -->
116
+ ### Codex: pending publication mediation
117
+
118
+ When the Codex `gh pr create` guard denies a canonical request, do not invoke `gh` again or retry through another tool. Prepare the request through the source CLI by sending the proposed tool payload on stdin to `navori codex approval request`; this only records a bounded pending request. Use the returned UUID with `navori codex approval relay <UUID> --cwd <checkout>` to obtain its read-only summary for the principal.
119
+
120
+ Present the repository, base, current head, title, body-file reference and draft flag, together with the UUID and a reconstructed command for the user to review and run themselves. Keep the body in its referenced file; do not paste raw command text or body contents into the request summary. Caller-supplied origin/provenance, JSON, principal assertions, and a mediation claim are not human consent. The result remains pending until an applicable authenticated human/host consent path exists; the agent must not publish or retry the denied effect through another tool. Unsupported request forms remain denied.
121
+ <!-- /navori:if -->
122
+
115
123
  1. **Gather context** (curated, not the whole repo). The PR diff is against `{{prTarget}}`:
116
124
  - `git log origin/{{prTarget}}..HEAD --oneline` — commits included.
117
125
  - `git diff origin/{{prTarget}}...HEAD --stat` — always.
@@ -4,7 +4,7 @@ description: Strict reviewer — approves or rejects a diff against CLAUDE.md an
4
4
  tools: Read, Glob, Grep, Bash, Write
5
5
  model: {{models.reviewer}}
6
6
  effort: {{effort.reviewer}}
7
- maxWords: 3046
7
+ maxWords: 3320
8
8
  ---
9
9
 
10
10
  # Reviewer Agent
@@ -41,7 +41,10 @@ You are a strict reviewer. Your only function is to **approve or reject**. You d
41
41
  ```
42
42
 
43
43
  `begin` stamps the content identity BEFORE you read the diff, so it must run first.
44
+ <!-- navori:if onCodex -->
44
45
 
46
+ For Codex, use the source CLI in this actual checkout to run `bun packages/cli/src/index.ts receipt review begin <feature> --target {{prTarget}} --dir .navori/state/handoffs --json` before reading the diff, then retain its nonce. Immediately after a successful begin and before the expensive full gate, run `bun packages/cli/src/index.ts receipt review preflight <feature> --host-cwd <actual-host-checkout> --json`, using the actual host session checkout for `--host-cwd`. This option supplies diagnostic context only; it never overrides or supplies native reviewer identity. If the host or binding is unsupported or incompatible, stop before the full gate and report the preflight diagnostic. Static compatibility does not prove which fields a live native hook payload contains. After deciding the verdict and writing the review artifacts, run `bun packages/cli/src/index.ts receipt review seal <feature> --nonce <nonce> --target {{prTarget}} --dir .navori/state/handoffs --json`, followed by `bun packages/cli/src/index.ts handoff log-review <feature> --dir .navori/state/handoffs`. The native runtime must independently identify the reviewer; the review must bind to the current producer handoff, publication bytes, and target. Missing, stale, unknown, same-producer, or drifted evidence cannot be approved. Never supply free-form actor IDs or claim the begin proves the reviewer read the diff. An unchanged-bytes publication commit may retain correlation without repeating the full gate.
47
+ <!-- /navori:if -->
45
48
  A nonzero `behind` count is a hard stop: do not review, approve, or write a
46
49
  receipt. A target-only file would otherwise look like a deletion in this
47
50
  worktree and the receipt would sign that phantom deletion.
@@ -62,6 +65,9 @@ Does the diff do EXACTLY what was asked? You don't review style yet.
62
65
  <!-- navori:if planTiers -->
63
66
  - With a workplan: an assigned `A<n>` without evidence in `acceptance`, a file outside the workplan's files without a covering decision, or `navori plan classify <feature> --diff` returning a higher level than declared → `CHANGES_REQUESTED`.
64
67
  - With a workplan: run `navori plan check <feature> --json`; every `A<n>` marked `cumplido` without recorded evidence (the routing-watch hook records it only when the host ran the exact `command`) is a finding → `CHANGES_REQUESTED`.
68
+ - `progress-unevidenced` or `progress-evidence-stale` → `CHANGES_REQUESTED`; `progress-unevidenced-accepted` is not a rejection.
69
+ - With phases: check each phase's receipt with `navori receipt gate --feature <feature> --phase <phase>`.
70
+ - The receipt, the gate log and `navori plan escalation-reset` are local attestations without authentication: seatbelts, not boundaries. Codex review independence depends on `CODEX_THREAD_ID`.
65
71
  <!-- /navori:if -->
66
72
  - Screen changes are reviewed on the **diff + the repo's tests** — browser/visual validation is **not a default gate**. Only when the user explicitly requested a visual check in this task do you confirm it happened; if it was requested and skipped, flag it. Never escalate a screen change to a human just because no browser check ran.
67
73
 
@@ -96,12 +102,12 @@ The full gate runs once, on the round that signs; a rejected round never spends
96
102
  If `navori gate <kind>` prints no first line `navori gate <kind>: exit <N> — log <path>`, it did not run (an old navori prints help and exits 0): run the literal `{{qualityGate.fast}}` or `{{qualityGate.full}}` instead. Read only the verdict.
97
103
 
98
104
  <!-- navori:if onCodex -->
99
- Read the verdict to verify (exit code + failure count), but leave only `exit 0` + the summary line in the report (e.g. `N passed`); when red, only the failing tail. Don't drag the full verbose log turn to turn. This evidence —green gate over the final diff, this cycle— is what the `publisher` reuses so it does **not** re-run the gate, so it must be fresh and over the diff that's going to be committed. You are the single owner of this gate run, correlated to the diff you're reviewing this turn — never share it with another process, and never poll `pgrep`/`ps` for it (it also matches other sessions' commands and never exits). A timeout is never a success signal.
105
+ Read verdict (exit code and failure count); report only `exit 0` plus summary or failing tail. The publisher reuses fresh evidence for the final diff. You are the single owner of this gate run, correlated to the diff you're reviewing this turn: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal.
100
106
 
101
107
  Start `{{gateRun.full}}` once with the available execution tool, in the foreground, without shell `&` or detached/background execution. Retain the exact returned continuation handle and, if it yields while running, continue polling that same handle as the same reviewer until the process reports its actual final exit status, exit code, and failure count. For `exec_command`, continue only its exact returned `session_id` via `write_stdin` with empty `chars`; for a yielded tool-native cell, use only its matching cell handle and native wait. A yield is neither a timeout nor success. Do not detach, share, transfer, restart or replay the gate. Without the process's actual final exit, exit code, and failure count, the review is incomplete: report `BLOCKED` and write no receipt. An actual timeout, cancellation, rejection, or lost handle also leaves it `BLOCKED` with no receipt. A nonzero final exit or gate failures follow the existing `QUALITY_MISS` path. The full gate runs only on the round about to sign `APPROVED`; a round ending in `CHANGES_REQUESTED` uses the scoped gate and the assigned `A<n>` commands, and runs no full gate.
102
108
  <!-- /navori:if -->
103
109
  <!-- navori:if-not onCodex -->
104
- Read the verdict to verify (exit code + failure count), but leave only `exit 0` + the summary line in the report (e.g. `N passed`); when red, only the failing tail. Don't drag the full verbose log turn to turn. This evidence —green gate over the final diff, this cycle— is what the `publisher` reuses so it does **not** re-run the gate, so it must be fresh and over the diff that's going to be committed. You are the single owner of this gate run: the only handle that exists is this Bash call itself, correlated to the diff you're reviewing this turn — never share it with another process, and never poll `pgrep`/`ps` for it (it also matches other sessions' commands and never exits). A timeout is never a success signal. Run `{{gateRun.full}}` in the foreground with the Bash tool's max `timeout`; if the full gate can exceed it, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run its `&&`-chained steps one by one in the foreground, each under the timeout — never background it (no shell `&`, no `run_in_background`, no `Monitor`), you won't be re-woken to read the result. If no chained step fits under any foreground timeout, stop and report `BLOCKED` instead of improvising a background wait. The full gate runs only on the round about to sign `APPROVED`; a round ending in `CHANGES_REQUESTED` uses the scoped gate and the assigned `A<n>` commands, and runs no full gate.
110
+ Read verdict (exit code and failure count); report only `exit 0` plus summary or failing tail. Publisher reuses fresh gate evidence. You are the single owner of this gate run, correlated to the diff you're reviewing this turn: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal. Run `{{gateRun.full}}` in the foreground with Bash's maximum timeout. If it may exceed that, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run `&&`-chained steps separately in the foreground, never background them (no shell `&`, no `run_in_background`, no `Monitor`). If no chained step fits under any foreground timeout, report `BLOCKED` instead of improvising a background wait. A yield is neither timeout nor success; run evidence must match the final diff being signed and include exit code and failure count. Missing actual process exit, code, or failure count; timeout, cancellation, rejection, or lost handle means `BLOCKED` with no receipt. Nonzero exit or gate failures mean `QUALITY_MISS`. The full gate runs only on the round about to sign `APPROVED`; `CHANGES_REQUESTED` uses the scoped gate and assigned `A<n>` commands only.
105
111
  <!-- /navori:if-not -->
106
112
 
107
113
 
@@ -24,7 +24,7 @@ This role is registered ahead of the typed-handoff migration. Until a producer/c
24
24
  - To create source code, tests, user-authored documentation, or session-state Markdown.
25
25
  - When the evidence is missing, malformed, or belongs to another feature: report the named blocker without fabricating an artifact.
26
26
  <!-- /navori:if-not --><!-- navori:if scribeOwnsMarkdown -->
27
- You are the sole author of the Markdown that lands in the diff. Two jobs, in this order when both apply: **render** a producer's JSON evidence into its prescribed Markdown handoff (R5), and **draft** the prose any `markdownRequests` entry asks for (R7) — reading the repo as needed for accuracy, never taking a design decision the request doesn't state. Preserve the producer's feature identity, status, evidence, files and verification exactly; add no claim the evidence doesn't carry.
27
+ You are the author of the status and deliverable Markdown that lands in the diff: handoffs, `progress/`, specs, `docs/`, README and any other human-facing text. The `implementer` writes AI doctrine itself (agents, skills, managed context and hooks, in their `core-assets` source or an engine's doctrine folders) with the edit tools and `navori render --apply`; never rewrite it from a `markdownRequests` entry unless the request names that path. Two jobs, in this order when both apply: **render** a producer's JSON evidence into its prescribed Markdown handoff (R5), and **draft** the prose any `markdownRequests` entry asks for (R7) — reading the repo as needed for accuracy, never taking a design decision the request doesn't state. Preserve the producer's feature identity, status, evidence, files and verification exactly; add no claim the evidence doesn't carry.
28
28
 
29
29
  ## Preflight the handoff
30
30
 
@@ -40,6 +40,10 @@ The `implementer` (or another JSON-handoff producer) leaves `.navori/state/hando
40
40
  ## Apply markdownRequests (R7)
41
41
 
42
42
  For every entry, draft the prose from its `intent` and `evidence` — read the surrounding file for tone and format, but never invent a decision the request doesn't state; an ambiguous intent is a blocker to report, not a guess to make. Apply it in the producer's own worktree and branch, touching ONLY the listed `path`s, and commit it yourself — a commit separate from the producer's.
43
+ <!-- navori:if onCodex -->
44
+
45
+ After the requested prose and every official derived render are final, run `bun packages/cli/src/index.ts handoff attest <feature> --target {{prTarget}} --json` from the producer's actual checkout. This records the designated scribe through the supported native runtime. Preserve the producer JSON byte-for-byte, including its hash; do not claim forensic authorship of every write. If native role or ownership is missing, leave it pending and report the blocker. Do not commit before the reviewer approves.
46
+ <!-- /navori:if -->
43
47
 
44
48
  ## When NOT to trigger
45
49
 
@@ -278,6 +278,38 @@ navori_bash_codex_outcome() {
278
278
  return 0
279
279
  }
280
280
 
281
+ # Codex 0.162.1 observed CommandExecution: argv, file-URL cwd and completed exit.
282
+ # Missing effective cwd/command or an uncorrelated item never produces evidence.
283
+ navori_codex_acceptance_payload() {
284
+ NV_CODEX_PAYLOAD="$payload" node -e '
285
+ const fs = require("node:fs");
286
+ const { fileURLToPath } = require("node:url");
287
+ try {
288
+ const p = JSON.parse(process.env.NV_CODEX_PAYLOAD);
289
+ if (!p.tool_use_id || !p.transcript_path || p.is_interrupt || p.tool_input?.run_in_background) process.exit(0);
290
+ const fd = fs.openSync(p.transcript_path, "r");
291
+ let lines;
292
+ try {
293
+ const size = fs.fstatSync(fd).size;
294
+ const len = Math.min(size, 1048576);
295
+ const b = Buffer.alloc(len);
296
+ fs.readSync(fd, b, 0, len, size-len);
297
+ lines = b.toString("utf8").split("\n");
298
+ } finally { fs.closeSync(fd); }
299
+ for (let i = lines.length-1; i >= 0; i--) {
300
+ let r; try { r = JSON.parse(lines[i]); } catch { continue; }
301
+ const item = r?.payload?.item;
302
+ if (r?.type !== "event_msg" || r?.payload?.type !== "item_completed" || item?.type !== "CommandExecution" || item.id !== p.tool_use_id) continue;
303
+ if (item.exit_code !== 0 || item.status !== "completed" || typeof item.cwd !== "string") break;
304
+ if (!Array.isArray(item.command) || item.command.length !== 3 || !["-lc", "-c"].includes(item.command[1]) || item.command[2] !== p.tool_input?.command) break;
305
+ const cwd = item.cwd.startsWith("file:") ? fileURLToPath(item.cwd) : item.cwd;
306
+ if (!cwd.startsWith("/") || /[\r\n]/.test(cwd)) break;
307
+ process.stdout.write(JSON.stringify({...p, cwd}));
308
+ break;
309
+ }
310
+ } catch {}'
311
+ }
312
+
281
313
  # Called by `routing-watch.sh` once it knows the tool is `Bash`, and only under
282
314
  # `claude-post-tool-use` (Codex fires PostToolUse on failure too, so no signal).
283
315
  navori_bash_success_lane() {
@@ -0,0 +1,127 @@
1
+ # Versioned reader shared with the CLI preflight. Node builtins only; no CLI dependency.
2
+ # navori:review-edit-binding/v1
3
+ nv_review_edit_binding() {
4
+ if ! command -v node >/dev/null 2>&1; then
5
+ printf '%s\n' 'node-unavailable'
6
+ return 1
7
+ fi
8
+ node - "$@" <<'NV_REVIEW_EDIT_BINDING'
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const cp = require('node:child_process');
12
+ const crypto = require('node:crypto');
13
+ const [hostInput, thread, role, prefixes, ...outputs] = process.argv.slice(2);
14
+ const neutral = '.navori/state/handoffs';
15
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
+ const slug = /^[a-z0-9][a-z0-9._-]*$/;
17
+ const hex = /^[a-f0-9]{64}$/;
18
+ const deny = (reason) => { throw new Error(reason); };
19
+ const hash = (raw) => crypto.createHash('sha256').update(raw).digest('hex');
20
+ function git(cwd, ...args) {
21
+ const r = cp.spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
22
+ cwd, encoding: 'utf8', timeout: 2000, maxBuffer: 1048576,
23
+ });
24
+ if (r.status !== 0) deny('git-unavailable');
25
+ return r.stdout.trim();
26
+ }
27
+ /** Reject lexical traversal and every symlink, including a not-yet-existing leaf's ancestors. */
28
+ function physical(input) {
29
+ if (typeof input !== 'string' || !path.isAbsolute(input) || input.split(/[\\/]/).includes('..')) deny('unsafe-path');
30
+ let cursor = path.parse(input).root;
31
+ for (const part of input.slice(cursor.length).split(path.sep).filter(Boolean)) {
32
+ cursor = path.join(cursor, part);
33
+ try { if (fs.lstatSync(cursor).isSymbolicLink()) deny('symlink-path'); }
34
+ catch (e) { if (!(e instanceof Error) || !('code' in e) || e.code !== 'ENOENT') throw e; }
35
+ }
36
+ return path.resolve(input);
37
+ }
38
+ /** Read one exact file; index/stamp reads have individual 64 KiB bounds. */
39
+ function json(file, bounded = false) {
40
+ physical(file);
41
+ const fd = fs.openSync(file, 'r');
42
+ try {
43
+ if (bounded) {
44
+ if (!fs.fstatSync(fd).isFile() || fs.fstatSync(fd).size > 65536) deny('oversized-binding');
45
+ const buf = Buffer.alloc(65537);
46
+ const count = fs.readSync(fd, buf, 0, buf.length, 0);
47
+ if (count > 65536) deny('oversized-binding');
48
+ return JSON.parse(buf.subarray(0, count).toString('utf8'));
49
+ }
50
+ return JSON.parse(fs.readFileSync(fd, 'utf8'));
51
+ } finally { fs.closeSync(fd); }
52
+ }
53
+ function checkout(cwd) {
54
+ physical(cwd);
55
+ return fs.realpathSync(git(cwd, 'rev-parse', '--show-toplevel'));
56
+ }
57
+ function identity(cwd) {
58
+ return {
59
+ checkout: checkout(cwd),
60
+ gitDir: fs.realpathSync(git(cwd, 'rev-parse', '--path-format=absolute', '--git-dir')),
61
+ commonDir: fs.realpathSync(git(cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir')),
62
+ };
63
+ }
64
+ function registered(host, target) {
65
+ const entries = git(host, 'worktree', 'list', '--porcelain', '-z').split('\0');
66
+ return entries.includes(`worktree ${host}`) && entries.includes(`worktree ${target}`);
67
+ }
68
+ /** Bind one target/feature to the native event thread, current owner and two exact outputs. */
69
+ function validate(host, output) {
70
+ physical(output);
71
+ const match = /^(.*)\/\.navori\/state\/handoffs\/review_([^/]+)\.(md|json)$/.exec(output);
72
+ if (!match || !slug.test(match[2])) deny('not-review-output');
73
+ const [, target, feature] = match;
74
+ const actual = identity(target);
75
+ if (actual.checkout !== target || actual.commonDir !== host.commonDir ||
76
+ !registered(host.checkout, target)) deny('unregistered-checkout');
77
+ const state = path.join(target, neutral);
78
+ const index = json(path.join(state, `review_${feature}.${thread}.edit.json`), true);
79
+ if (!index || Object.keys(index).sort().join(',') !== 'feature,nonce,thread,v' ||
80
+ index.v !== 1 || index.feature !== feature || index.thread !== thread ||
81
+ !uuid.test(index.nonce)) deny('invalid-index');
82
+ const stamp = json(path.join(state, `review_${feature}.${index.nonce}.begin.json`), true);
83
+ const b = stamp.editBinding;
84
+ const a = stamp.reviewer;
85
+ if (!b || b.v !== 1 || b.checkout !== target || b.gitDir !== actual.gitDir ||
86
+ b.commonDir !== actual.commonDir || b.stateDir !== neutral || typeof b.target !== 'string' ||
87
+ !b.target || stamp.v !== 1 || stamp.alg !== 'navori-content/v1' ||
88
+ stamp.feature !== feature || stamp.nonce !== index.nonce) deny('invalid-binding');
89
+ if (!a || a.host !== 'codex' || a.version !== '0.162.1' || a.role !== 'reviewer' ||
90
+ a.thread !== thread || !uuid.test(a.root) || !uuid.test(a.parent) ||
91
+ !hex.test(a.sourceHash) || checkout(a.sourceCwd) !== host.checkout) deny('wrong-native-actor');
92
+ const owner = json(path.join(state, `ownership_${feature}.json`));
93
+ if (!owner || owner.feature !== feature || owner.cwd !== target || owner.target !== b.target ||
94
+ owner.handoff !== stamp.ownership || !hex.test(stamp.ownership) ||
95
+ owner.fingerprint !== stamp.fingerprint || owner.base !== stamp.base ||
96
+ !Array.isArray(owner.actors) || !owner.actors.length ||
97
+ owner.actors.some((p) => p.host !== 'codex' || p.version !== '0.162.1' ||
98
+ !['implementer', 'scribe'].includes(p.role) || !uuid.test(p.thread) ||
99
+ p.root !== a.root || p.thread === thread)) deny('ownership-mismatch');
100
+ const handoff = path.join(state, `impl_${feature}.json`);
101
+ physical(handoff);
102
+ if (hash(fs.readFileSync(handoff)) !== owner.handoff) deny('handoff-changed');
103
+ return `${target}\0${feature}`;
104
+ }
105
+ try {
106
+ if (role !== 'reviewer' || !uuid.test(thread)) deny('native-agent-id-unavailable');
107
+ if (!prefixes.split('\n').some((p) => {
108
+ const prefix = p.replace(/\/$/, '');
109
+ return prefix && `${neutral}/review_output.json`.startsWith(`${prefix}/`);
110
+ })) deny('role-prefix-denied');
111
+ if (!outputs.length) deny('missing-output');
112
+ const host = identity(hostInput);
113
+ const checked = new Set();
114
+ for (const output of outputs) {
115
+ physical(output);
116
+ const match = /^(.*)\/\.navori\/state\/handoffs\/review_([^/]+)\.(md|json)$/.exec(output);
117
+ if (!match) deny('not-review-output');
118
+ const key = `${match[1]}\0${match[2]}`;
119
+ if (!checked.has(key)) checked.add(validate(host, output));
120
+ }
121
+ process.stdout.write('bound-review-output\n');
122
+ } catch (e) {
123
+ process.stdout.write(`${e instanceof Error ? e.message : 'invalid-binding'}\n`);
124
+ process.exitCode = 1;
125
+ }
126
+ NV_REVIEW_EDIT_BINDING
127
+ }
@@ -37,10 +37,25 @@
37
37
  # The contract in `implementer.md` is the rule; this catches the slip, not the
38
38
  # workaround.
39
39
  #
40
+ # AI-DOCTRINE EXCEPTION (spec 0052, R49; decision by Ulises, 2026-10-14). The
41
+ # doctrine of the harness (agents, skills, managed context, hooks) is part of the
42
+ # code the implementer changes: routing it through the scribe cost an
43
+ # implementer -> scribe -> implementer round per doctrine milestone for no gain.
44
+ # So the EDIT TOOLS (Write/Edit/NotebookEdit/every apply_patch path) pass on a
45
+ # `.md`/`.mdx` under the closed prefix list `navori_doctrine_prefixes` below,
46
+ # relative to the repo root. This partly reverts 0030's "no .md at all"; the
47
+ # scribe still owns status Markdown and human-facing deliverables. The Bash forms
48
+ # are NOT relaxed: command text gives no trustworthy path. Absolute paths are
49
+ # trimmed against the repo root; a `..` segment or a path outside the root blocks.
50
+ # Symlinks: same accepted limitation as above. Editing a generated mirror
51
+ # (`.claude/agents/...`) is overwritten by the next render; the contract tells the
52
+ # implementer to edit the source and run `navori render --apply` (contract, not
53
+ # barrier). Pi keeps the same list in `engines/pi/role-tools.ts`.
54
+ #
40
55
  # The managed block below is regenerated by `navori render` and must NOT be
41
56
  # edited by hand.
42
57
  # Blocking classification (#1117):
43
- # - implementer writing a Markdown file: hard — Markdown ownership; the message names the route (markdownRequests).
58
+ # - implementer writing a Markdown file outside the AI-doctrine prefixes: hard — Markdown ownership; the message names the allowed prefixes and the route (markdownRequests).
44
59
  set -euo pipefail
45
60
 
46
61
  # Command/field extraction (payload → $cmd, $payload). Shared body, single
@@ -96,12 +111,28 @@ fi
96
111
 
97
112
  navori_tool=$(nv_tool)
98
113
 
114
+ # Closed list of AI-doctrine prefixes (R49), relative to the repo root.
115
+ navori_doctrine_prefixes=(
116
+ packages/core/core-assets/agents/
117
+ packages/core/core-assets/skills/
118
+ packages/core/core-assets/managed/
119
+ packages/core/core-assets/hooks/
120
+ .claude/agents/
121
+ .claude/skills/
122
+ .claude/context/
123
+ .claude/hooks/
124
+ .codex/agents/
125
+ .codex/hooks/
126
+ .codex/orchestrator.md
127
+ .agents/skills/
128
+ .pi/navori/
129
+ )
130
+
99
131
  block() {
100
132
  echo "[navori] BLOCKED by implementer-no-markdown: $1" >&2
101
- # Both progress paths, never one alone: `placeHook` ships this byte-identical
102
- # to Claude and Codex (#389), so a message hardcoding `.claude/progress`
103
- # would misdirect a Codex implementer to a path that is not its own.
104
- echo "[navori] the implementer does not write Markdown (spec 0030, R1). Put the change in \"markdownRequests\", inside .claude/progress/impl_<feature>.json (or .codex/progress/impl_<feature>.json under Codex) — the scribe applies it in this worktree." >&2
133
+ # `placeHook` ships this byte-identical to Claude and Codex (#389), so the
134
+ # message names the engine-neutral handoff home, never one engine's folder.
135
+ echo "[navori] the implementer writes Markdown only for AI doctrine (spec 0052, R49), with the edit tools, under: ${navori_doctrine_prefixes[*]}. Anything else (progress/, specs/, docs/, README, handoffs, paths with '..') goes in \"markdownRequests\", inside .navori/state/handoffs/impl_<feature>.json — the scribe applies it in this worktree. Bash never writes Markdown." >&2
105
136
  navori_audit_verdict="block"
106
137
  navori_audit_reason="$1"
107
138
  exit 2
@@ -116,6 +147,36 @@ is_md_path() {
116
147
  esac
117
148
  }
118
149
 
150
+ # True when $1 (a `.md`/`.mdx` target of an EDIT tool) is AI doctrine: relative to
151
+ # the repo root, or absolute and inside it; never with a `..` segment.
152
+ is_doctrine_path() {
153
+ local p=$1 rel root candidate prefix
154
+ case "$p" in
155
+ .. | ../* | */.. | */../*) return 1 ;;
156
+ esac
157
+ case "$p" in
158
+ /*)
159
+ rel=""
160
+ for candidate in "${nv_project_dir:-}" "$(git -C "$(payload_field cwd)" rev-parse --show-toplevel 2>/dev/null || true)"; do
161
+ root=${candidate%/}
162
+ [ -n "$root" ] || continue
163
+ case "$p" in
164
+ "$root"/*) rel=${p#"$root"/}; break ;;
165
+ esac
166
+ done
167
+ [ -n "$rel" ] || return 1
168
+ ;;
169
+ *) rel=${p#./} ;;
170
+ esac
171
+ for prefix in "${navori_doctrine_prefixes[@]}"; do
172
+ case "$prefix" in
173
+ */) case "$rel" in "$prefix"?*) return 0 ;; esac ;;
174
+ *) [ "$rel" = "$prefix" ] && return 0 ;;
175
+ esac
176
+ done
177
+ return 1
178
+ }
179
+
119
180
  case "$navori_tool" in
120
181
  Write | Edit)
121
182
  # Codex's `apply_patch` can touch several files in ONE call (Add/Update/
@@ -123,7 +184,7 @@ case "$navori_tool" in
123
184
  # line, so every one of them is checked, not just the first.
124
185
  while IFS= read -r navori_path; do
125
186
  [ -n "$navori_path" ] || continue
126
- if is_md_path "$navori_path"; then
187
+ if is_md_path "$navori_path" && ! is_doctrine_path "$navori_path"; then
127
188
  block "$navori_tool sobre '$navori_path'"
128
189
  fi
129
190
  done <<EOF
@@ -135,7 +196,7 @@ EOF
135
196
  ;;
136
197
  NotebookEdit)
137
198
  navori_path=$(payload_field tool_input.notebook_path)
138
- if is_md_path "$navori_path"; then
199
+ if is_md_path "$navori_path" && ! is_doctrine_path "$navori_path"; then
139
200
  block "NotebookEdit sobre '$navori_path'"
140
201
  fi
141
202
  navori_audit_verdict="allow"
@@ -14,7 +14,7 @@ set -euo pipefail
14
14
 
15
15
  # Every matching command needs this literal token, so its absence from the raw
16
16
  # payload proves this hook cannot apply and avoids parsing the payload at all.
17
- TRIGGER_TOKENS='approved-by delivery-baseline delivery-queue navori master'
17
+ TRIGGER_TOKENS='approved-by delivery-baseline delivery-queue escalation-reset navori master'
18
18
  # navori:include gate-trigger
19
19
 
20
20
  has_trigger_token "${payload:-}" || exit 0
@@ -56,12 +56,20 @@ QUEUE_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-queue([[:space:]]|\$).
56
56
  DELIVERY_REVIEW_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-(review|decision|publication|revoke)([[:space:];&|)<>]|\$)"
57
57
  DELIVERY_CRITERION_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-criterion([[:space:]]|\$).*--approved-by([[:space:]=]|\$)"
58
58
  DELIVERY_REFRESH_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-slice([[:space:]]|\$).*--refresh([[:space:]=]|\$)"
59
- TRIGGER_RE=".*${BOUND}(${PART_RE}|${CLOSE_RE}|${BASELINE_RE}|${QUEUE_RE}|${DELIVERY_REVIEW_RE}|${DELIVERY_CRITERION_RE}|${DELIVERY_REFRESH_RE})"
59
+ RESET_RE="${NAVORI}[[:space:]]+plan[[:space:]]+escalation-reset([[:space:]]|\$).*--approved-by([[:space:]=]|\$)"
60
+ TRIGGER_RE=".*${BOUND}(${PART_RE}|${CLOSE_RE}|${BASELINE_RE}|${QUEUE_RE}|${DELIVERY_REVIEW_RE}|${DELIVERY_CRITERION_RE}|${DELIVERY_REFRESH_RE}|${RESET_RE})"
60
61
  # A command substitution starts a new command: make it a segment of its own so
61
62
  # the `VAR=$(` prefix peeling in the shared scan cannot swallow it.
62
63
  navori_subst='$('
63
64
  navori_semi='; '
64
65
  cmd="${cmd//"$navori_subst"/$navori_semi}"
66
+ # Quotes hide the boundary from `BOUND` in wrapped forms (`sh -c 'navori master
67
+ # close'`, `eval "…"`, `navori master "close"`): delete ONLY `"` and `'`. Never
68
+ # `\`: `navori_count_triggers` joins a `\<newline>` continuation first, and
69
+ # removing the backslash here would split the segment (spec 0052 R36). The cost
70
+ # is asking on `echo "navori master close"`, the safe direction. A word split by
71
+ # quotes (`navo"ri`) is deliberate obfuscation this hook does not claim to catch.
72
+ cmd="${cmd//[\"\']/}"
65
73
  is_scan_trigger "$cmd" || exit 0
66
74
 
67
75
  approval_description="a manual criterion"
@@ -71,6 +79,7 @@ case "$cmd" in
71
79
  *delivery-publication*) approval_description="the release or deploy reference for this exact accepted delivery; this records publication, it does not deploy" ;;
72
80
  *delivery-revoke*) approval_description="revocation of the active authority generation and prior effective proof" ;;
73
81
  *delivery-slice*) approval_description="the current-queue reverification refresh; pending criteria reset and normal plan approval is required again" ;;
82
+ *escalation-reset*) approval_description="another review round after two rejections at level 2 or higher" ;;
74
83
  *delivery-baseline*|*delivery-queue*) approval_description="the delivery baseline or bounded queue" ;;
75
84
  esac
76
85
 
@@ -1,21 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # Advisory-only main-session model recommendation. The hook reads only payload
4
- # fields recorded in host-contracts.ts; `/model` remains the user's native,
5
- # explicit switch path. It never writes navori.config.json or agent profiles.
3
+ # Startup/lifecycle modes advise the main session; `/model` remains the user's
4
+ # explicit switch path. The fixed Codex PreToolUse mode rewrites future child
5
+ # arguments or denies recoverable failures. No mode writes project config or profiles.
6
6
  set -euo pipefail
7
7
 
8
8
  payload=$(cat 2>/dev/null) || payload=""
9
9
  mode=${1:-}
10
10
 
11
- # The advisor has no effect on permissions or control flow, but its execution
12
- # must remain observable while audit mode is active. Its three Claude entry
13
- # points share one script, so derive the host phase from the explicit mode
14
- # rather than guessing from payload fields.
11
+ # Lifecycle advice has no permission effect; the fixed native PreToolUse mode
12
+ # does affect tool control flow. Audit both through their explicit host phase.
15
13
  case "$mode" in
16
14
  claude-session-start|codex-session-start) navori_audit_phase="SessionStart" ;;
17
15
  claude-post-model-switch) navori_audit_phase="PostModelSwitch" ;;
18
16
  claude-stop) navori_audit_phase="Stop" ;;
17
+ codex-pretool-profile) navori_audit_phase="PreToolUse" ;;
19
18
  *) navori_audit_phase="unknown" ;;
20
19
  esac
21
20
  navori_audit_name="model-advisor"
@@ -24,12 +23,100 @@ navori_audit_log() { :; }
24
23
  # navori:include audit-repo
25
24
  # navori:include audit-log
26
25
  navori_audit_begin
26
+ navori_profile_verdict="skip"
27
27
  navori_audit_on_exit() {
28
- navori_audit_log "skip" || true
28
+ navori_audit_log "$navori_profile_verdict" || true
29
29
  return 0
30
30
  }
31
31
  trap navori_audit_on_exit EXIT
32
32
 
33
+ # Fixed native argument rewrite, before the advisory subagent skip. Recoverable
34
+ # failures deny; an outer host timeout/crash can fail open and remains uncertain.
35
+ if [ "$mode" = "codex-pretool-profile" ]; then
36
+ if [ "${#payload}" -gt 1048576 ]; then
37
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"profile-hook-input-too-large; effective-selection-pending"}}'
38
+ navori_profile_verdict="block"
39
+ exit 0
40
+ fi
41
+ profile_exit=0
42
+ profile_output=$(node 3<<<"$payload" <<'PROFILE_NODE'
43
+ const { spawn } = require("node:child_process");
44
+ const rawPayload = require("node:fs").readFileSync(3, "utf8");
45
+ const deny = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "profile-hook-cli-unavailable-or-invalid; effective-selection-pending" } };
46
+ let child;
47
+ let timer;
48
+ let finished = false;
49
+ const finish = (value, code) => {
50
+ if (finished) return;
51
+ finished = true;
52
+ clearTimeout(timer);
53
+ if (child?.pid) {
54
+ try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
55
+ }
56
+ process.stdout.write(JSON.stringify(value) + "\n");
57
+ process.exitCode = code;
58
+ };
59
+ let original;
60
+ try { original = JSON.parse(rawPayload); }
61
+ catch { finish(deny, 2); }
62
+ if (!finished) {
63
+ const bytes = Buffer.byteLength(rawPayload);
64
+ if (bytes > 1024 * 1024) finish(deny, 2);
65
+ else {
66
+ child = spawn("navori", ["codex", "profile", "hook"], { detached: true, stdio: ["pipe", "pipe", "pipe"] });
67
+ const chunks = [];
68
+ let size = 0;
69
+ timer = setTimeout(() => finish(deny, 2), 11_000);
70
+ child.once("error", () => finish(deny, 2));
71
+ child.stdin.on("error", () => finish(deny, 2));
72
+ child.stdout.on("data", chunk => {
73
+ size += chunk.length;
74
+ if (size > 1024 * 1024) finish(deny, 2);
75
+ else chunks.push(chunk);
76
+ });
77
+ // Audit belongs on stderr; do not forward untrusted CLI diagnostics/payloads.
78
+ child.stderr.on("data", () => {});
79
+ child.once("close", (code, signal) => {
80
+ if (finished) return;
81
+ if (code !== 0 || signal) return finish(deny, 2);
82
+ try {
83
+ const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
84
+ if (result !== null && typeof result === "object" && !Array.isArray(result) && Object.getPrototypeOf(result) === Object.prototype && Object.keys(result).length === 0) return finish({}, 3);
85
+ const output = result?.hookSpecificOutput;
86
+ if (output?.hookEventName !== "PreToolUse") return finish(deny, 2);
87
+ if (output.permissionDecision === "deny" && typeof output.permissionDecisionReason === "string") return finish(result, 2);
88
+ const updated = output.updatedInput;
89
+ const input = original?.tool_input;
90
+ if (output.permissionDecision !== "allow" || !updated || !input || Array.isArray(updated) || Array.isArray(input) || typeof updated !== "object" || typeof input !== "object" || typeof updated.model !== "string" || !updated.model || typeof updated.reasoning_effort !== "string" || !updated.reasoning_effort) return finish(deny, 2);
91
+ const keys = new Set([...Object.keys(input), ...Object.keys(updated)]);
92
+ for (const key of keys) {
93
+ if (key === "model" || key === "reasoning_effort") continue;
94
+ if (JSON.stringify(input[key]) !== JSON.stringify(updated[key])) return finish(deny, 2);
95
+ }
96
+ finish(result, 0);
97
+ } catch { finish(deny, 2); }
98
+ });
99
+ child.stdin.end(rawPayload);
100
+ }
101
+ }
102
+ PROFILE_NODE
103
+ ) || profile_exit=$?
104
+ # Capture first: a missing/fatal/signaled supervisor cannot leak a partial
105
+ # response or append a second JSON document after an already valid denial.
106
+ if [ -n "$profile_output" ] && { [ "$profile_exit" -eq 0 ] || [ "$profile_exit" -eq 2 ] || [ "$profile_exit" -eq 3 ]; }; then
107
+ printf '%s\n' "$profile_output"
108
+ else
109
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"profile-hook-supervisor-unavailable; effective-selection-pending"}}'
110
+ profile_exit=2
111
+ fi
112
+ case "$profile_exit" in
113
+ 0) navori_profile_verdict="allow" ;;
114
+ 3) navori_profile_verdict="skip" ;;
115
+ *) navori_profile_verdict="block" ;;
116
+ esac
117
+ exit 0
118
+ fi
119
+
33
120
  # `Stop` fires for the main agent, but a session started with `--agent` carries
34
121
  # `agent_type`, and any payload with `agent_id` belongs to a subagent: neither
35
122
  # can advise, so their `node` spawn is pure waste. Discard it here with a