navori 0.4.1 → 0.5.0

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 (38) hide show
  1. package/dist/assets/core/core-assets/agents/commit-pr-pilot.md +16 -6
  2. package/dist/assets/core/core-assets/agents/explorer.md +2 -2
  3. package/dist/assets/core/core-assets/agents/implementer.md +2 -2
  4. package/dist/assets/core/core-assets/agents/leader.md +3 -1
  5. package/dist/assets/core/core-assets/agents/researcher.md +2 -2
  6. package/dist/assets/core/core-assets/agents/reviewer.md +17 -6
  7. package/dist/assets/core/core-assets/agents/ticket-audit.md +1 -1
  8. package/dist/assets/core/core-assets/hooks/_partials/extract-cmd.sh +21 -0
  9. package/dist/assets/core/core-assets/hooks/_partials/gate-trigger.sh +59 -0
  10. package/dist/assets/core/core-assets/hooks/guard-destructive.sh +18 -29
  11. package/dist/assets/core/core-assets/hooks/quality-gate-pre-commit.sh +24 -80
  12. package/dist/assets/core/core-assets/hooks/session-start-context.sh +17 -1
  13. package/dist/assets/core/core-assets/hooks/subagent-stop-handoff.sh +1 -1
  14. package/dist/assets/core/core-assets/lib-skills/citty.md +74 -0
  15. package/dist/assets/core/core-assets/lib-skills/clack.md +81 -0
  16. package/dist/assets/core/core-assets/lib-skills/jest.md +87 -0
  17. package/dist/assets/core/core-assets/lib-skills/playwright.md +66 -0
  18. package/dist/assets/core/core-assets/lib-skills/supertest.md +69 -0
  19. package/dist/assets/core/core-assets/lib-skills/testing-library.md +66 -0
  20. package/dist/assets/core/core-assets/lib-skills/vitest.md +67 -0
  21. package/dist/assets/core/core-assets/managed/cierre-sesion.md +1 -1
  22. package/dist/assets/core/core-assets/managed/codex-cross-review.md +14 -0
  23. package/dist/assets/core/core-assets/managed/formato-respuesta.md +1 -1
  24. package/dist/assets/core/core-assets/managed/orquestacion.md +4 -2
  25. package/dist/assets/core/core-assets/skills/dominio.md +67 -0
  26. package/dist/assets/core/core-assets/skills/ticket-intake.md +3 -3
  27. package/dist/assets/plugins/acli/plugin.json +12 -1
  28. package/dist/assets/plugins/codegraph/skills/codegraph-rung.md +2 -2
  29. package/dist/assets/plugins/engram/plugin.json +9 -3
  30. package/dist/assets/plugins/gh/plugin.json +2 -2
  31. package/dist/assets/plugins/jscpd/managed/jscpd-protocol.md +1 -1
  32. package/dist/assets/plugins/jscpd/plugin.json +2 -1
  33. package/dist/assets/plugins/jscpd/scripts/check-jscpd.sh +23 -81
  34. package/dist/assets/plugins/semgrep/managed/semgrep-protocol.md +3 -2
  35. package/dist/assets/plugins/semgrep/plugin.json +3 -2
  36. package/dist/assets/plugins/semgrep/scripts/check-semgrep.sh +24 -83
  37. package/dist/index.js +390 -319
  38. package/package.json +5 -3
@@ -47,21 +47,31 @@ Open that specific file and confirm its verdict is `APPROVED` and that its scope
47
47
 
48
48
  An absent file, ambiguous (more than one candidate), or with a verdict/scope that doesn't match the current feature → does NOT count as approved: abort, tell the user the review is missing, and never assume a generic `APPROVED`.
49
49
 
50
- **Content receipt (R2+): the diff must still match what was approved.** The APPROVED verdict is bound to the reviewed bytes via `.claude/progress/receipt.txt` (written by the `reviewer`, one `<blob-sha> <path>` line per reviewed file). Before committing, the approval has to cover the diff in **both** directions — coverage (every shipping file was reviewed) and no drift (no reviewed file changed its bytes):
50
+ **Content receipt (R2+): the diff must still match what was approved.** The APPROVED verdict is bound to the reviewed bytes via `.claude/progress/receipt.txt` (written by the `reviewer`, one `<blob-sha> <path>` line per reviewed file, or `deleted <path>` for a removed one). Before committing, the approval has to cover the diff in **both** directions — coverage (every shipping file was reviewed) and no drift (no reviewed file changed its bytes):
51
51
 
52
52
  ```bash
53
53
  # 1) COVERAGE: shipping files the receipt never listed → reviewer never saw them.
54
- # Same diff set the reviewer captured (tracked vs target + untracked), so the
55
- # sets line up 1:1 with no spurious mismatches.
54
+ # EXACT same diff set the reviewer captured tracked-vs-target + untracked,
55
+ # minus the harness's own progress/ files (same grep the receipt applies) — so
56
+ # the sets line up 1:1 with no spurious mismatches. A git-persisted progress/
57
+ # update never counts as "uncovered"; deletions DO stay in the set (the receipt
58
+ # records them as `deleted <path>`) so a removed file can't ship unreviewed.
56
59
  comm -23 \
57
- <({ git diff --name-only "origin/{{prTarget}}"; git ls-files --others --exclude-standard; } | sort -u) \
60
+ <({ git diff --name-only "origin/{{prTarget}}"; git ls-files --others --exclude-standard; } \
61
+ | sort -u | grep -vE '^(\.claude/progress/|progress/)') \
58
62
  <(grep -v '^#' .claude/progress/receipt.txt | sed 's/^[^ ]* //' | sort -u)
59
63
 
60
- # 2) DRIFT: a reviewed file whose bytes changed since the review.
64
+ # 2) DRIFT: a reviewed file whose bytes changed since the review. A `deleted`
65
+ # marker means the reviewer signed off on the removal → drift only if the file
66
+ # came back.
61
67
  while IFS= read -r line; do
62
68
  case "$line" in ''|'#'*) continue ;; esac
63
69
  blob=${line%% *}; path=${line#* }
64
- [ "$(git hash-object "$path" 2>/dev/null)" = "$blob" ] || echo "DRIFT: $path"
70
+ if [ "$blob" = deleted ]; then
71
+ [ -e "$path" ] && echo "DRIFT: $path (reappeared since review)"
72
+ else
73
+ [ "$(git hash-object "$path" 2>/dev/null)" = "$blob" ] || echo "DRIFT: $path"
74
+ fi
65
75
  done < .claude/progress/receipt.txt
66
76
  ```
67
77
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: explorer
3
3
  description: Broad map of an area or module of the repo. Returns structure, dependencies, and entry points. Does not modify code.
4
- tools: Read, Glob, Grep, Bash
4
+ tools: Read, Glob, Grep, Bash, Write
5
5
  model: {{models.explorer}}
6
6
  effort: {{effort.explorer}}
7
7
  ---
@@ -25,7 +25,7 @@ If the question is specific ("where is X?"), it's not you — it's `researcher`.
25
25
 
26
26
  1. Read `CLAUDE.md` to understand the repo's conventions.
27
27
  2. Define the scope: a folder, a logical module, a file pattern. The orchestrator should hand it to you precisely; if it arrives ambiguous, return `blocked` naming the options (folder X / module Y / pattern Z) so it re-sends it scoped — don't guess.
28
- 3. Walk from the entry points (routes, module root exports, `index.ts`) toward the leaves. For each level, list files and their brief role. Apply `.claude/skills/structural-search.md` to locate shapes and entry points without reading whole files.
28
+ 3. Walk from the entry points (routes, module root exports, `index.ts`) toward the leaves. For each level, list files and their brief role. Apply `.claude/skills/structural-search/SKILL.md` to locate shapes and entry points without reading whole files.
29
29
  4. Identify reverse dependencies: which external modules consume this module? That indicates the "blast radius" of changing something here.
30
30
  5. Write `.claude/progress/explore_<area>.md`:
31
31
 
@@ -26,7 +26,7 @@ You execute **a single** task from start to verification. You don't orchestrate,
26
26
  ```
27
27
 
28
28
  - `Expected files: <list>`
29
- 3. **Implement** following the repo's flow (the leader's "Project rules" define the concrete pattern: layers, libs, paths, naming). To locate the code to touch, apply `.claude/skills/structural-search.md`: open only the confirmed span, don't read whole files by reflex.
29
+ 3. **Implement** following the repo's flow (the leader's "Project rules" define the concrete pattern: layers, libs, paths, naming). To locate the code to touch, apply `.claude/skills/structural-search/SKILL.md`: open only the confirmed span, don't read whole files by reflex.
30
30
  4. **Quality gate** (mandatory before returning):
31
31
 
32
32
  ```bash
@@ -70,7 +70,7 @@ No speculative abstractions: no interface / layer / flag with a single "just in
70
70
 
71
71
  ## Evidence-based completion (gate before the report)
72
72
 
73
- Before returning `done -> .claude/progress/impl_<feature>.md`, apply `.claude/skills/verify-before-done.md`. Summary of the Iron Law:
73
+ Before returning `done -> .claude/progress/impl_<feature>.md`, apply `.claude/skills/verify-before-done/SKILL.md`. Summary of the Iron Law:
74
74
 
75
75
  | Claim you're going to make | Required output | Not sufficient |
76
76
  |---|---|---|
@@ -93,10 +93,12 @@ Expected files:
93
93
 
94
94
  - `.claude/progress/audit_ticket_<TICKET-ID>.md` — deep analysis of one ticket (`ticket-audit`)
95
95
  - `.claude/progress/audit_deep_<scope>.md` — deep read-only audit of a module/area/repo with no ticket (`auditor`)
96
+ - `.claude/progress/plan_<scope>.md` — the `auditor`'s prioritized plan that accompanies a deep audit
96
97
  - `.claude/progress/explore_<topic>.md` — broad map (`explorer`)
97
98
  - `.claude/progress/research_<question>.md` — scoped question (`researcher`)
98
99
  - `.claude/progress/impl_<feature>.md` — the `implementer`'s report (includes its `Status: DONE | BLOCKED`)
99
100
  - `.claude/progress/review_<feature>.md` — the `reviewer`'s verdict
101
+ - `.claude/progress/receipt.txt` — the `reviewer`'s content receipt on `APPROVED` (binds the diff to the reviewed bytes; consumed by `commit-pr-pilot`)
100
102
 
101
103
  **Path separation (don't mix):** `.claude/progress/` is ONLY for these ephemeral handoffs between agents. The **session state** (current task, plan, blockers) lives in `progress/current.md` (repo root, persists in git) and you consolidate it **YOU, only**: subagents never write it. When an `implementer` reports `blocked` in its `impl_<feature>.md`, you record the blocker in `progress/current.md` along with the next step.
102
104
 
@@ -105,7 +107,7 @@ Expected files:
105
107
  When `.claude/progress/review_<feature>.md` contains `APPROVED`:
106
108
 
107
109
  1. Invoke `commit-pr-pilot` to draft the title + body following the repo's format and open the PR.
108
- 2. Pre-flight on you before invoking: clean working tree, you're not on `{{branchBase}}`, `{{qualityGate.fast}}` green this turn, `gh auth status` ok.
110
+ 2. Pre-flight on you before invoking: you're not on `{{branchBase}}`, `{{qualityGate.fast}}` green this turn, `gh auth status` ok. (Do NOT require a clean working tree — the pilot's trigger IS an uncommitted diff ready to commit, and the pilot, not you, owns that commit.)
109
111
  3. Return to the user only the PR URL + title.
110
112
 
111
113
  If the review returned `CHANGES_REQUESTED`, do NOT invoke `commit-pr-pilot`: launch another `implementer` with the list of changes and restart the cycle.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: researcher
3
3
  description: Read-only investigation of a scoped question. Reads the repo, writes findings to a file. Does not modify code.
4
- tools: Read, Glob, Grep, Bash
4
+ tools: Read, Glob, Grep, Bash, Write
5
5
  model: {{models.researcher}}
6
6
  effort: {{effort.researcher}}
7
7
  ---
@@ -28,7 +28,7 @@ If the question is broad ("map the whole module X for me"), it's not you — it'
28
28
  3. Run the search:
29
29
  - Primary method: the native `Grep` (content) and `Glob` (files by name/pattern) tools. They're read-only, fast (ripgrep), and don't ask for permission.
30
30
  - Fallback only for what the tools don't cover (git history with `git grep`, FS metadata with `find`): shell commands. Chained with pipes/redirects they ask for confirmation, so reserve the shell for when `Grep`/`Glob` fall short.
31
- - For semantic questions (not just string match), apply `.claude/skills/structural-search.md`: locate the right region and open only the confirmed span; don't read whole files by reflex.
31
+ - For semantic questions (not just string match), apply `.claude/skills/structural-search/SKILL.md`: locate the right region and open only the confirmed span; don't read whole files by reflex.
32
32
  4. Validate each finding: open the file, confirm the match means what it seems (sometimes a `grep` matches comments or strings unrelated to the concept).
33
33
  5. Write `.claude/progress/research_<question-slug>.md`:
34
34
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: Strict reviewer. Approves or rejects the implementer's work against CLAUDE.md. Does not edit code.
4
- tools: Read, Glob, Grep, Bash
4
+ tools: Read, Glob, Grep, Bash, Write
5
5
  model: {{models.reviewer}}
6
6
  effort: {{effort.reviewer}}
7
7
  ---
@@ -26,11 +26,16 @@ You are a strict reviewer. Your only function is to **approve or reject**. You d
26
26
  git status --short
27
27
  git fetch origin {{prTarget}} --quiet
28
28
  git diff --stat
29
- git diff origin/{{prTarget}}...HEAD
29
+ # two-dot: the FULL working tree vs the target (committed AND uncommitted),
30
+ # the exact set the receipt fingerprints below. Three-dot (`...HEAD`) would show
31
+ # only committed changes, but in the harness the diff is still uncommitted — so
32
+ # the review command would read empty while the receipt signs the working tree.
33
+ git diff "origin/{{prTarget}}"
34
+ git ls-files --others --exclude-standard # untracked files (new, not yet staged)
30
35
  ```
31
36
 
32
37
  3. **Re-review** (if there's already a `.claude/progress/review_<feature>.md` from a previous cycle): focus the *reading* on (a) that the issues listed there are resolved and (b) the files the `implementer` reports having touched in this cycle (`impl_<feature>.md`). Don't re-review from scratch the already-approved code that didn't change; the full quality gate is still run anyway — a change can break something outside the delta.
33
- 4. Apply `.claude/skills/verify-before-done.md` to every `[x]` that depends on evidence. The quality gate is run **this turn, in Pass 2** (not before: a `SPEC_MISS` in Pass 1 doesn't need it — don't spend the gate on a diff you're going to reject on spec). Don't assume from the implementer's cached report.
38
+ 4. Apply `.claude/skills/verify-before-done/SKILL.md` to every `[x]` that depends on evidence. The quality gate is run **this turn, in Pass 2** (not before: a `SPEC_MISS` in Pass 1 doesn't need it — don't spend the gate on a diff you're going to reject on spec). Don't assume from the implementer's cached report.
34
39
 
35
40
  ### Pass 1 — Spec compliance
36
41
 
@@ -52,7 +57,7 @@ Does the diff do EXACTLY what was asked? You don't review style yet.
52
57
 
53
58
  Does the code match the repo's conventions? Here you do review style/naming/types.
54
59
 
55
- Apply `.claude/skills/review-diff.md` — the full checklist by dimensions, with severities. Its CRITICAL/HIGH map to the ≥80 issues below; MEDIUM to the informational observations. Summary of the minimum to validate against `CLAUDE.md` and the leader's "Project rules":
60
+ Apply `.claude/skills/review-diff/SKILL.md` — the full checklist by dimensions, with severities. Its CRITICAL/HIGH map to the ≥80 issues below; MEDIUM to the informational observations. Summary of the minimum to validate against `CLAUDE.md` and the leader's "Project rules":
56
61
 
57
62
  - **Conventions**: naming, path aliases, folder structure.
58
63
  - **Centralized types**: no inline `type`/`interface` where the convention says "outside".
@@ -87,12 +92,18 @@ printf '# navori-receipt v1 feature=<feature>\n' > .claude/progress/receipt.txt
87
92
  | sort -u \
88
93
  | grep -vE '^(\.claude/progress/|progress/)' \
89
94
  | while IFS= read -r f; do
90
- [ -f "$f" ] && printf '%s %s\n' "$(git hash-object "$f")" "$f"
95
+ if [ -f "$f" ]; then
96
+ printf '%s %s\n' "$(git hash-object "$f")" "$f" # live file → blob sha
97
+ else
98
+ printf 'deleted %s\n' "$f" # removed file → deletion marker
99
+ fi
91
100
  done >> .claude/progress/receipt.txt
92
101
  ```
93
102
 
94
103
  It captures the working-tree bytes under review (committed **and** uncommitted). The `grep -v` drops the harness's own ephemeral progress files (the receipt, `impl_*`, `review_*`) — they never get committed, so fingerprinting them would be self-referential noise. Skip the whole step for `CHANGES_REQUESTED` — a rejected diff has nothing to bind.
95
104
 
105
+ A **removed** file has no bytes to hash, so it's recorded as `deleted <path>` instead of a blob sha. Keeping the deletion **in** the receipt is what closes the RDD cycle: the `commit-pr-pilot` coverage check is path-based, so it still sees the path (a deletion can't ship unreviewed), and both its drift check and the pre-commit hook read the `deleted` marker as "must stay absent" — flagging drift only if the file reappears. The shipping set the pilot compares against is then byte-for-byte the set you signed here (same `grep -vE`, deletions included), so a git-persisted `progress/` update or a removed file never shows up as "uncovered" and livelocks the close.
106
+
96
107
  ### Confidence scoring per finding (Pass 2)
97
108
 
98
109
  Each issue is scored 0-100. Only issues ≥80 block APPROVED. Issues 50-79 are listed as "informational observations" (they don't block). <50 = don't report.
@@ -164,7 +175,7 @@ CHANGES_REQUESTED -> .claude/progress/review_<feature>.md
164
175
 
165
176
  - ❌ Never skip Pass 1 (spec compliance). If the code is pretty but doesn't do what was asked, it's `CHANGES_REQUESTED`.
166
177
  - ❌ Never include as a blocker (in "Issues ≥80") a finding with confidence <80.
167
- - ✅ Apply `.claude/skills/verify-before-done.md` before marking APPROVED: each `[x]` must be backed by evidence run this turn (not from the implementer's cached report).
178
+ - ✅ Apply `.claude/skills/verify-before-done/SKILL.md` before marking APPROVED: each `[x]` must be backed by evidence run this turn (not from the implementer's cached report).
168
179
  - ❌ Never approve with `{{qualityGate.full}}` red.
169
180
  - ❌ Never approve if the new code **adds new errors or warnings** vs baseline.
170
181
  - ❌ Never approve new code with explicit or implicit `any` without a valid `// any justified: <reason>`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: ticket-audit
3
3
  description: Deep analysis of a complex ticket before implementing. Produces audit_ticket_<ID>.md with root cause, affected areas, and a decomposition plan.
4
- tools: Read, Glob, Grep, Bash
4
+ tools: Read, Glob, Grep, Bash, Write
5
5
  model: {{models.ticketAudit}}
6
6
  effort: {{effort.ticketAudit}}
7
7
  ---
@@ -0,0 +1,21 @@
1
+ # Shared hook boilerplate — inlined into each hook at render time (see the
2
+ # include directive in the source scripts + lib/hook-includes.ts). Single source
3
+ # of truth for the sibling gate scripts; DO NOT copy this body back into a hook
4
+ # by hand (that is the drift #225/#261 removed).
5
+ #
6
+ # PreToolUse(Bash) passes the tool input on stdin. Extract .tool_input.command
7
+ # WITHOUT hard-depending on jq (NOT preinstalled on macOS): try jq, then node
8
+ # (Claude Code's own runtime), then a best-effort sed unwrap. No command
9
+ # extracted → empty $cmd, and each caller decides what that means (the gate
10
+ # scripts scan defensively; guard-destructive waves the command through).
11
+ payload=$(cat)
12
+ extract_cmd() {
13
+ if command -v jq >/dev/null 2>&1; then
14
+ printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null && return 0
15
+ fi
16
+ if command -v node >/dev/null 2>&1; then
17
+ printf '%s' "$payload" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(s)?.tool_input?.command??""))}catch{}})' 2>/dev/null && return 0
18
+ fi
19
+ printf '%s' "$payload" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p'
20
+ }
21
+ cmd=$(extract_cmd)
@@ -0,0 +1,59 @@
1
+ # Shared gate detector — inlined into each hook at render time (see the include
2
+ # directive in the source scripts + lib/hook-includes.ts). The caller MUST set
3
+ # $TRIGGER_RE (an ERE) before the include; it decides which git ops this hook
4
+ # gates. Single source of truth for the FIX B/C wrapper-peeling logic; DO NOT
5
+ # copy this body into a hook by hand.
6
+ #
7
+ # Detect whether $1 (a possibly-compound command) invokes a gated operation.
8
+ # Splits $1 on the shell separators && || ; | and newlines, strips leading
9
+ # whitespace plus wrapper words (`(`, `\`, `command `) and `VAR=value` env
10
+ # prefixes from each segment, and returns 0 if ANY segment STARTS with a gated
11
+ # `git …` invocation on a word boundary (matched by $TRIGGER_RE). Replaces
12
+ # literal-prefix `case` matching, which silently skipped the gate for
13
+ # `cd x && git commit`, `echo y; git push`, or a leading space (#88: NEVER skip
14
+ # the gate silently). Matching a segment START means a quoted `echo "git commit"`
15
+ # does NOT trigger it. Known limitation: it cannot see through `sh -c`, `eval`,
16
+ # or obfuscation — a seatbelt, not a sandbox.
17
+ is_scan_trigger() {
18
+ local input="$1" segment
19
+ # FIX B: join `\<newline>` continuations into a space FIRST, so a command
20
+ # split across lines with a trailing backslash stays ONE logical segment
21
+ # (otherwise the subcommand/flag lands in a segment not starting with git).
22
+ input="${input//\\$'\n'/ }"
23
+ input="${input//&&/$'\n'}"
24
+ input="${input//||/$'\n'}"
25
+ input="${input//;/$'\n'}"
26
+ input="${input//|/$'\n'}"
27
+ # `<<<` feeds the already-expanded value as data — no re-evaluation — so a
28
+ # command that contains backticks/$() is inspected, never executed.
29
+ while IFS= read -r segment; do
30
+ segment="${segment#"${segment%%[![:space:]]*}"}" # strip leading ws
31
+ # FIX C: peel wrappers so `(git …`, `\git`, `command git …` and
32
+ # `VAR=val git …` all reduce to a plain `git …` before matching.
33
+ while [[ "$segment" == \(* ]]; do # strip leading ( runs
34
+ segment="${segment#\(}"
35
+ segment="${segment#"${segment%%[![:space:]]*}"}"
36
+ done
37
+ segment="${segment#\\}" # strip a leading backslash (\git)
38
+ while [[ "$segment" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do # strip VAR=val prefixes
39
+ case "$segment" in
40
+ *[[:space:]]*)
41
+ segment="${segment#*[[:space:]]}"
42
+ segment="${segment#"${segment%%[![:space:]]*}"}"
43
+ ;;
44
+ *) segment=""; break ;;
45
+ esac
46
+ done
47
+ if [[ "$segment" == command\ * ]]; then # strip a leading `command ` word
48
+ segment="${segment#command }"
49
+ segment="${segment#"${segment%%[![:space:]]*}"}"
50
+ fi
51
+ # FIX C: allow git global options between `git` and the subcommand
52
+ # (`git -c k=v commit`, `git -C /repo push`). $TRIGGER_RE's trailing boundary
53
+ # keeps `git commitgraph` / `git config …` from matching.
54
+ if printf '%s' "$segment" | grep -qE "$TRIGGER_RE"; then
55
+ return 0
56
+ fi
57
+ done <<< "$input"
58
+ return 1
59
+ }
@@ -21,32 +21,21 @@
21
21
  # bottom — `$cmd` is already parsed and in scope there.
22
22
  set -euo pipefail
23
23
 
24
- # Extract .tool_input.command from the PreToolUse payload WITHOUT hard-depending
25
- # on jq (NOT preinstalled on macOS — a missing jq used to make this guard wave
26
- # every command through). Try jq, then node (Claude Code's own runtime), and if
27
- # no JSON parser is on PATH fall back to a best-effort sed unwrap so the guard
28
- # still inspects the command instead of failing open.
29
- payload=$(cat)
30
- extract_cmd() {
31
- if command -v jq >/dev/null 2>&1; then
32
- printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null && return 0
33
- fi
34
- if command -v node >/dev/null 2>&1; then
35
- printf '%s' "$payload" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(s)?.tool_input?.command??""))}catch{}})' 2>/dev/null && return 0
36
- fi
37
- # No JSON parser on PATH: pull the "command" string out with sed. Best-effort
38
- # (won't handle a literal embedded quote), but far better than failing open.
39
- printf '%s' "$payload" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p'
40
- }
41
- cmd=$(extract_cmd)
24
+ # Command extraction (payload → $cmd). Shared body, single source of truth.
25
+ # A missing JSON parser used to make this guard wave every command through; the
26
+ # shared extractor falls back to sed so it still inspects the command.
27
+ # navori:include extract-cmd
42
28
  [ -z "$cmd" ] && exit 0
43
29
 
44
- base="{{branchBase}}"
30
+ # branchBase is shell-quoted at render time via the shq: marker (#197): a
31
+ # hostile branchBase in navori.config.json lands here as an inert literal,
32
+ # never executable.
33
+ base={{shq:branchBase}}
45
34
 
46
35
  block() {
47
- echo "[navori] BLOQUEADO por guard-destructive: $1" >&2
48
- echo "[navori] comando: $cmd" >&2
49
- echo "[navori] si es intencional, corre el comando mismo fuera del agente." >&2
36
+ echo "[navori] BLOCKED by guard-destructive: $1" >&2
37
+ echo "[navori] command: $cmd" >&2
38
+ echo "[navori] if intentional, run the command yourself outside the agent." >&2
50
39
  exit 2
51
40
  }
52
41
 
@@ -86,7 +75,7 @@ git_cp='(^|[[:space:]]|[;&|])git([[:space:]]+-[a-zA-Z-]+(=[^[:space:]]+)?([[:spa
86
75
  # (`git commit -qn`/`-nq`) without a hyphen-word in a message tripping it.
87
76
  if printf '%s' "$scan" | grep -qE "${git_cp}([[:space:]]|.)*--no-verify" \
88
77
  || printf '%s' "$scan_flags" | grep -qE "${git_cp}([[:space:]]|.)*[[:space:]]-[a-zA-Z]*n[a-zA-Z]*([[:space:]]|\$)"; then
89
- block "git commit/push con --no-verify (saltarse los hooks/gates)"
78
+ block "git commit/push with --no-verify (skipping hooks/gates)"
90
79
  fi
91
80
 
92
81
  # 2. Force-push to the base branch. force-with-lease is allowed (safe rebase
@@ -98,13 +87,13 @@ if printf '%s' "$scan" | grep -qE '(^|[[:space:]]|[;&|])git([[:space:]]+-[a-zA-Z
98
87
  && printf '%s' "$scan" | grep -qE '(--force([[:space:]]|$)|[[:space:]]-f([[:space:]]|$)|[[:space:]]\+)' \
99
88
  && ! printf '%s' "$scan" | grep -qE 'force-with-lease' \
100
89
  && printf '%s' "$scan" | grep -qE "(^|[[:space:]+/])${base}([[:space:]]|\$)"; then
101
- block "force-push a la rama base '${base}'"
90
+ block "force-push to the base branch '${base}'"
102
91
  fi
103
92
 
104
93
  # 3. rm -rf with variable indirection or absolute/home roots that static deny
105
94
  # globs miss (e.g. PATH=/; rm -rf $PATH).
106
95
  if printf '%s' "$cmd" | grep -qE '(^|[[:space:]])rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|-[a-zA-Z]*f[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*[rf][a-zA-Z]*[[:space:]]+("?\$|/[[:space:]]*$|~[[:space:]]*$)'; then
107
- block "rm recursivo sobre variable / raíz / home"
96
+ block "recursive rm over a variable / root / home"
108
97
  fi
109
98
 
110
99
  # 4. Fork bomb.
@@ -114,13 +103,13 @@ fi
114
103
 
115
104
  # 5. Writing to a raw block device (wipes a disk/partition).
116
105
  if printf '%s' "$cmd" | grep -qE '(of=/dev/(sd|nvme|disk|hd)|>[[:space:]]*/dev/(sd|nvme|disk|hd))'; then
117
- block "escritura directa a un dispositivo de bloque"
106
+ block "direct write to a block device"
118
107
  fi
119
108
 
120
109
  # navori:user-section
121
- # user: agrega guards adicionales acá. `$cmd` ya tiene el comando completo
122
- # (incluye comandos compuestos) y `block "<motivo>"` aborta con exit 2.
123
- # Ejemplo:
110
+ # user: add extra guards here. `$cmd` already holds the full command (compound
111
+ # commands included) and `block "<reason>"` aborts with exit 2.
112
+ # Example:
124
113
  #
125
114
  # if printf '%s' "$cmd" | grep -qE 'drop[[:space:]]+(table|database)'; then
126
115
  # block "DROP TABLE/DATABASE"
@@ -9,20 +9,8 @@
9
9
  # bottom — they keep `cmd` and the original exit codes in scope.
10
10
  set -euo pipefail
11
11
 
12
- # Extract .tool_input.command WITHOUT hard-depending on jq (not preinstalled on
13
- # macOS). Try jq, then node (Claude Code's own runtime), then a best-effort sed
14
- # unwrap. If nothing extracts a command $cmd stays empty and the gate skips.
15
- payload=$(cat)
16
- extract_cmd() {
17
- if command -v jq >/dev/null 2>&1; then
18
- printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null && return 0
19
- fi
20
- if command -v node >/dev/null 2>&1; then
21
- printf '%s' "$payload" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(s)?.tool_input?.command??""))}catch{}})' 2>/dev/null && return 0
22
- fi
23
- printf '%s' "$payload" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p'
24
- }
25
- cmd=$(extract_cmd)
12
+ # Command extraction (payload → $cmd). Shared body, single source of truth.
13
+ # navori:include extract-cmd
26
14
 
27
15
  # Detect the project's REAL package manager from lockfiles / package.json, so a
28
16
  # gate command hardcoded to one PM (e.g. `pnpm run ...`) can still run in a repo
@@ -59,7 +47,8 @@ run_gate() {
59
47
  # --- content receipt (RDD) --------------------------------------------------
60
48
  # Backstop that binds the commit to the exact bytes the reviewer approved. The
61
49
  # reviewer writes a receipt (`<blob-sha> <path>` lines, one per approved file,
62
- # via `git hash-object`) when it marks APPROVED; the commit-pr-pilot recomputes
50
+ # via `git hash-object`, plus `deleted <path>` for a file it approved removing)
51
+ # when it marks APPROVED; the commit-pr-pilot recomputes
63
52
  # it before committing and consumes it after. THIS is the mechanical net for a
64
53
  # direct `git commit` that skips the pilot: if an approved file's content
65
54
  # drifted since the review (rebase, human tweak, follow-up edit), block. It
@@ -99,6 +88,10 @@ check_content_receipt() {
99
88
  path=${line#* } # path = the rest (may contain spaces)
100
89
  [ -n "$blob" ] && [ "$blob" != "$path" ] || continue
101
90
  printf '%s\n' "$commit_set" | grep -qxF "$path" || continue # not in this commit → ignore
91
+ if [ "$blob" = deleted ]; then # reviewer approved the removal
92
+ [ -e "$path" ] && drift="${drift} - ${path} (reappeared)"$'\n' # drift only if it came back
93
+ continue
94
+ fi
102
95
  now=$(git hash-object "$path" 2>/dev/null || true)
103
96
  if [ "$now" != "$blob" ]; then
104
97
  drift="${drift} - ${path}"$'\n'
@@ -108,75 +101,26 @@ check_content_receipt() {
108
101
  if [ -n "$drift" ]; then
109
102
  echo "[navori] APPROVED content changed since review (receipt mismatch). Commit BLOCKED." >&2
110
103
  printf '%s' "$drift" >&2
111
- echo "[navori] Re-run the reviewer over the current diff, or override with 'git commit --no-verify'." >&2
104
+ echo "[navori] Re-run the reviewer over the current diff. To bypass, run the commit yourself outside the agent." >&2
112
105
  echo "[navori] To clear a stale receipt: rm $receipt" >&2
113
106
  exit 2
114
107
  fi
115
108
  }
116
109
 
117
- # --- shared gate detection (keep IN SYNC across sibling hooks) --------------
118
- # Detect a `git commit` invocation anywhere in a (possibly
119
- # compound) command. Splits $1 on the shell separators && || ; | and newlines,
120
- # strips leading whitespace plus simple `VAR=value` env prefixes from each
121
- # segment, and returns 0 if ANY segment STARTS with `git commit` on
122
- # a word boundary. Replaces literal-prefix `case` matching, which silently
123
- # skipped the gate for `cd x && git commit`, `echo y; git commit`, or a leading
124
- # space (#88: NEVER skip the gate silently). Because it matches a segment START,
125
- # a quoted `echo "git commit"` does NOT trigger it. Known limitation: it cannot
126
- # see through `sh -c`, `eval`, or obfuscation — a seatbelt, not a sandbox.
127
- # The IDENTICAL function body lives in the sibling gate scripts (they render
128
- # standalone, so there is no shared lib to import):
129
- # plugins/jscpd/scripts/check-jscpd.sh
130
- # plugins/semgrep/scripts/check-semgrep.sh
131
- is_git_commit() {
132
- local input="$1" segment
133
- # FIX B: join `\<newline>` continuations into a space FIRST, so a command
134
- # split across lines with a trailing backslash stays ONE logical segment
135
- # (otherwise the subcommand/flag lands in a segment not starting with git).
136
- input="${input//\\$'\n'/ }"
137
- input="${input//&&/$'\n'}"
138
- input="${input//||/$'\n'}"
139
- input="${input//;/$'\n'}"
140
- input="${input//|/$'\n'}"
141
- # `<<<` feeds the already-expanded value as data — no re-evaluation — so a
142
- # command that contains backticks/$() is inspected, never executed.
143
- while IFS= read -r segment; do
144
- segment="${segment#"${segment%%[![:space:]]*}"}" # strip leading ws
145
- # FIX C: peel wrappers so `(git …`, `\git`, `command git …` and
146
- # `VAR=val git …` all reduce to a plain `git …` before matching.
147
- while [[ "$segment" == \(* ]]; do # strip leading ( runs
148
- segment="${segment#\(}"
149
- segment="${segment#"${segment%%[![:space:]]*}"}"
150
- done
151
- segment="${segment#\\}" # strip a leading backslash (\git)
152
- while [[ "$segment" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do # strip VAR=val prefixes
153
- case "$segment" in
154
- *[[:space:]]*)
155
- segment="${segment#*[[:space:]]}"
156
- segment="${segment#"${segment%%[![:space:]]*}"}"
157
- ;;
158
- *) segment=""; break ;;
159
- esac
160
- done
161
- if [[ "$segment" == command\ * ]]; then # strip a leading `command ` word
162
- segment="${segment#command }"
163
- segment="${segment#"${segment%%[![:space:]]*}"}"
164
- fi
165
- # FIX C: allow git global options between `git` and the subcommand
166
- # (`git -c k=v commit`, `git -C /repo push`). Trailing boundary keeps
167
- # `git commitgraph` / `git config …` from matching as commit.
168
- if printf '%s' "$segment" | grep -qE '^git([[:space:]]+-[a-zA-Z-]+(=[^[:space:]]+)?([[:space:]]+[^-][^[:space:]]*)?)*[[:space:]]+commit([[:space:]]|$)'; then
169
- return 0
170
- fi
171
- done <<< "$input"
172
- return 1
173
- }
110
+ # Gate to `git commit` only. $TRIGGER_RE is consumed by the shared detector
111
+ # inlined below.
112
+ TRIGGER_RE='^git([[:space:]]+-[a-zA-Z-]+(=[^[:space:]]+)?([[:space:]]+[^-][^[:space:]]*)?)*[[:space:]]+commit([[:space:]]|$)'
113
+ # navori:include gate-trigger
174
114
 
175
- if is_git_commit "$cmd"; then
115
+ if is_scan_trigger "$cmd"; then
176
116
  # Content-bind first: refuse to commit bytes that drifted from the approval
177
117
  # before spending the fast gate on them.
178
118
  check_content_receipt
179
- gate="{{qualityGate.fast}}"
119
+ # qualityGate.fast is shell-quoted at render time via the shq: marker (#197).
120
+ # The gate string is still `eval`'d by run_gate below (running the gate is the
121
+ # feature), but quoting it here means a hostile qualityGate.fast survives as one
122
+ # literal token instead of injecting commands at variable-assignment time.
123
+ gate={{shq:qualityGate.fast}}
180
124
  gate_bin="${gate%% *}"
181
125
  if command -v "$gate_bin" >/dev/null 2>&1; then
182
126
  run_gate "$gate"
@@ -188,19 +132,19 @@ if is_git_commit "$cmd"; then
188
132
  # `exit 0` that handed a contributor zero quality gate without a word.
189
133
  detected_pm="$(detect_pm)"
190
134
  if is_pm "$gate_bin" && [ -n "$detected_pm" ] && [ "$detected_pm" != "$gate_bin" ] && command -v "$detected_pm" >/dev/null 2>&1; then
191
- echo "[navori] '$gate_bin' no está en PATH; uso el package manager detectado por lockfile: '$detected_pm'." >&2
135
+ echo "[navori] '$gate_bin' is not on PATH; using the lockfile-detected package manager: '$detected_pm'." >&2
192
136
  run_gate "$detected_pm ${gate#* }"
193
137
  else
194
- echo "[navori] quality-gate NO ejecutado: '$gate_bin' no está en PATH y no hay un package manager alternativo detectado que pueda correrlo." >&2
195
- echo "[navori] Commit BLOQUEADO para no saltarnos el gate en silencio. Instala '$gate_bin' o usa 'git commit --no-verify' si de verdad quieres saltártelo." >&2
138
+ echo "[navori] quality-gate NOT run: '$gate_bin' is not on PATH and no alternative package manager was detected that could run it." >&2
139
+ echo "[navori] Commit BLOCKED to avoid skipping the gate silently. Install '$gate_bin', or if you really want to skip it run the commit yourself outside the agent." >&2
196
140
  exit 2
197
141
  fi
198
142
  fi
199
143
  fi
200
144
 
201
145
  # navori:user-section
202
- # user: agrega checks adicionales acá. `$cmd` ya está parseado del input
203
- # de la tool. Ejemplo:
146
+ # user: add extra checks here. `$cmd` is already parsed from the tool input.
147
+ # Example:
204
148
  #
205
149
  # case "$cmd" in
206
150
  # 'git push'*)
@@ -29,7 +29,9 @@ add() { ctx="${ctx}${1}"$'\n'; }
29
29
 
30
30
  if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
31
31
  branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')
32
- base="{{branchBase}}"
32
+ # branchBase is shell-quoted at render time via the shq: marker (#197) so an
33
+ # untrusted branchBase can't inject a command here.
34
+ base={{shq:branchBase}}
33
35
  if [ "$branch" = "$base" ]; then
34
36
  add "Branch: ${branch} ⚠️ on the base branch — create a working branch before committing."
35
37
  else
@@ -59,6 +61,20 @@ if [ -n "$current" ]; then
59
61
  fi
60
62
  fi
61
63
 
64
+ # Workspace Dominio: canonical cross-repo knowledge for the workspace this repo
65
+ # belongs to (e.g. "coachee = user-profile.kind"), so agents don't relearn it
66
+ # wrong in every repo. The CLI owns the resolution (which workspace is cwd in +
67
+ # read the index); the hook stays dumb. Cheap pre-check first so the common
68
+ # no-workspace case never spawns the binary, and `|| true` so a missing/broken
69
+ # `navori` never blocks session startup. (spec 0011 §6.1)
70
+ if [ -d "$HOME/.navori/workspaces" ] && command -v navori >/dev/null 2>&1; then
71
+ dominio=$(navori dominio inject 2>/dev/null || true)
72
+ if [ -n "$dominio" ]; then
73
+ add ""
74
+ add "$dominio"
75
+ fi
76
+ fi
77
+
62
78
  [ -n "$ctx" ] || exit 0
63
79
 
64
80
  # Emit the JSON safely: node (best escaping) → jq → give up (exit 0, no context).
@@ -47,7 +47,7 @@ shopt -s nullglob
47
47
  for f in "$dir"/impl_*.md; do
48
48
  if is_blank "$f"; then
49
49
  note "$(basename "$f") vacío"
50
- elif ! grep -qi '^status:' "$f" 2>/dev/null; then
50
+ elif ! grep -qiE '^\*{0,2}status:?\*{0,2}' "$f" 2>/dev/null; then
51
51
  note "$(basename "$f") sin línea 'Status:'"
52
52
  fi
53
53
  done