navori 0.6.3 → 0.6.5

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.
@@ -0,0 +1,170 @@
1
+ # Shared audit-mode event recorder — inlined into each managed hook at render
2
+ # time (see the include directive in the source scripts + lib/hook-includes.ts).
3
+ #
4
+ # WHY (spec 0013): a hook is only visible to the transcript when it BLOCKS or
5
+ # INJECTS context. Every hook that runs and lets the action through is invisible,
6
+ # so `navori audit` could never answer "did the gate run, and what did it cost?".
7
+ # The transcript cannot be fixed — it is the host's format — so the harness
8
+ # records its own execution instead, and the session log becomes the source of
9
+ # truth for what the harness did.
10
+ #
11
+ # Expects `$payload` (the raw hook payload on stdin) to be in scope, and reads
12
+ # `$navori_audit_t0` for the start instant. Both are set by `navori_audit_begin`.
13
+ #
14
+ # Every variable read here carries a `:-` default ON PURPOSE: most managed hooks
15
+ # run under `set -euo pipefail`, where an unset variable ABORTS the hook. A
16
+ # recorder that can abort the thing it observes is worse than no recorder, so the
17
+ # partial must be safe to inline into `set -u` and `set +e` alike.
18
+ #
19
+ # FAIL-OPEN ABSOLUTE, and this matters more here than anywhere else: this code is
20
+ # inlined into hooks whose own contract is to never break a session. Observation
21
+ # must never become the reason an action fails, so every path returns 0 and
22
+ # nothing is ever written to stdout — a stray byte there would be interpreted by
23
+ # the host as hook output (context injection, or a block reason).
24
+
25
+ # Start the clock — but only after establishing that anything will be recorded.
26
+ #
27
+ # THE COST OF BEING OFF is the number that matters here: this code is inlined
28
+ # into hooks that fire on EVERY Bash call, and audit-mode is off for virtually
29
+ # every session of every user. An earlier version ran `perl` plus two `jq`
30
+ # invocations before it ever checked whether a log existed — three processes per
31
+ # hook, four hooks per command, ~48 ms on every single shell call for a feature
32
+ # nobody had turned on.
33
+ #
34
+ # So the gate is a pure-builtin one first: the per-repo audit directory only
35
+ # exists once audit-mode has been activated in this repo at least once. No
36
+ # subprocess, no parsing. Everything expensive lives behind it.
37
+ navori_audit_begin() {
38
+ navori_audit_on=0
39
+
40
+ navori_audit_root=${NAVORI_AUDITS_ROOT:-}
41
+ if [ -z "$navori_audit_root" ]; then
42
+ [ -n "${HOME:-}" ] || return 0
43
+ navori_audit_root=$HOME/.navori/audits
44
+ fi
45
+
46
+ # The gate tests the audit ROOT, not the per-repo directory.
47
+ #
48
+ # Deriving the repo cheaply would mean `${CLAUDE_PROJECT_DIR##*/}` — and that
49
+ # is WRONG: the authoritative repo comes from the payload's `cwd`, and the two
50
+ # differ whenever a hook fires inside an agent worktree, since the hook process
51
+ # starts in the main repo (#454). A gate built on the wrong name would silently
52
+ # record nothing exactly where the harness runs its parallel work.
53
+ #
54
+ # The root alone is enough for what this gate is for: a user who has never
55
+ # activated audit-mode anywhere has no `~/.navori/audits`, so the common case
56
+ # costs one stat and zero processes. Someone who does use audit-mode pays the
57
+ # parsing — which is the cost of the feature they turned on.
58
+ [ -d "$navori_audit_root" ] || return 0
59
+
60
+ navori_audit_on=1
61
+ navori_audit_t0=$(navori_audit_now)
62
+ }
63
+
64
+ # Milliseconds since epoch, spending a process only when it has to.
65
+ #
66
+ # `$EPOCHREALTIME` is a BUILTIN in bash 5 and in zsh (with zsh/datetime, which
67
+ # the harness's zsh path already has): no fork at all. Only a shell without it
68
+ # pays for `perl`, and only a machine without perl degrades to whole seconds —
69
+ # `date` has no portable millisecond format (GNU has %s%3N, BSD does not).
70
+ navori_audit_now() {
71
+ if [ -n "${EPOCHREALTIME:-}" ]; then
72
+ # `1756... .123456` → milliseconds, with pure parameter expansion.
73
+ navori_audit_epoch=${EPOCHREALTIME/,/.}
74
+ printf '%s%s' "${navori_audit_epoch%%.*}" "$(printf '%.3s' "${navori_audit_epoch#*.}")"
75
+ return 0
76
+ fi
77
+ perl -MTime::HiRes=time -e 'printf "%.0f", time*1000' 2>/dev/null \
78
+ || printf '%s' $(( $(date +%s 2>/dev/null || echo 0) * 1000 ))
79
+ }
80
+
81
+ # navori_audit_log <verdict> [reason]
82
+ #
83
+ # `name`, `phase`, `tool` and `source` come from the caller's own variables,
84
+ # which the render sets per hook — the partial never guesses which hook it is
85
+ # inlined into.
86
+ navori_audit_log() {
87
+ # The builtin-only gate from `navori_audit_begin`: when audit-mode was never
88
+ # activated in this repo, nothing below runs and no process is spawned.
89
+ [ "${navori_audit_on:-0}" = "1" ] || return 0
90
+ # No payload, no jq, no clock → nothing to record. Each of these is a normal
91
+ # state for a hook that bailed early, not an error worth surfacing.
92
+ [ -n "${payload:-}" ] || return 0
93
+ command -v jq >/dev/null 2>&1 || return 0
94
+
95
+ # ONE jq for every field, not one per field: this runs on each hook of each
96
+ # Bash call, and a fork is the most expensive thing in it. Newline-separated,
97
+ # read back positionally.
98
+ navori_audit_fields=$(printf '%s' "${payload:-}" | jq -r '[.session_id // "", .cwd // "", .agent_id // .subagent_id // ""] | .[]' 2>/dev/null) || return 0
99
+ navori_audit_session=${navori_audit_fields%%
100
+ *}
101
+ navori_audit_rest=${navori_audit_fields#*
102
+ }
103
+ navori_audit_cwd=${navori_audit_rest%%
104
+ *}
105
+ navori_audit_agent=${navori_audit_rest#*
106
+ }
107
+ [ -n "$navori_audit_session" ] || return 0
108
+ # Same character class the CLI enforces (#503): the id composes a path, so
109
+ # anything path-shaped means the payload is not what we think it is.
110
+ case "$navori_audit_session" in
111
+ *[!A-Za-z0-9_-]*) return 0 ;;
112
+ esac
113
+
114
+ [ -n "$navori_audit_cwd" ] || navori_audit_cwd=$PWD
115
+ navori_audit_repo=$(basename "$navori_audit_cwd" 2>/dev/null) || return 0
116
+ [ -n "$navori_audit_repo" ] || return 0
117
+
118
+ navori_audit_file=$navori_audit_root/$navori_audit_repo/session-$navori_audit_session.log
119
+
120
+ # The session may not be the marked one even in a repo that has been audited
121
+ # before. Also the writability check — a log that cannot be appended to is not
122
+ # an error, it is simply not recording.
123
+ [ -f "$navori_audit_file" ] || return 0
124
+ [ -w "$navori_audit_file" ] || return 0
125
+
126
+ # Volume valve, OFF by default.
127
+ #
128
+ # `PreToolUse(Bash)` chains four hooks, so every shell command leaves four
129
+ # lines and most are `skip` — a long session runs to thousands. Set
130
+ # NAVORI_AUDIT_SKIP_NOOPS=1 to drop the ones that did nothing.
131
+ #
132
+ # Default off on purpose: a `skip` is the ONLY evidence that a hook ran and
133
+ # decided it had no business acting, which is exactly what distinguishes it
134
+ # from a hook that never executed — the question that motivated recording
135
+ # hooks at all. The valve trades that away knowingly; it must not be the
136
+ # silent default.
137
+ if [ "${NAVORI_AUDIT_SKIP_NOOPS:-0}" = "1" ]; then
138
+ case "$1" in
139
+ skip|noop) return 0 ;;
140
+ esac
141
+ fi
142
+
143
+ navori_audit_end=$(navori_audit_now)
144
+ navori_audit_ms=$(( navori_audit_end - ${navori_audit_t0:-$navori_audit_end} ))
145
+ [ "$navori_audit_ms" -ge 0 ] 2>/dev/null || navori_audit_ms=0
146
+
147
+ navori_audit_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || navori_audit_ts=""
148
+ # `navori_audit_agent` came out of the same single jq above. It is what lets
149
+ # the report attribute a hook to a subagent WITHOUT guessing: with agents
150
+ # running in parallel their time windows overlap, so attribution by timestamp
151
+ # is the fallback, not the primary route.
152
+
153
+ printf '%s\n' "$(jq -cn \
154
+ --arg ts "$navori_audit_ts" \
155
+ --arg name "${navori_audit_name:-unknown}" \
156
+ --arg phase "${navori_audit_phase:-unknown}" \
157
+ --arg verdict "${1:-unknown}" \
158
+ --arg reason "${2:-}" \
159
+ --arg tool "${navori_audit_tool:-}" \
160
+ --arg src "${navori_audit_source:-core}" \
161
+ --arg agent "${navori_audit_agent:-}" \
162
+ --argjson ms "$navori_audit_ms" \
163
+ '{ts:$ts,event:"hook",name:$name,phase:$phase,verdict:$verdict,ms:$ms,source:$src}
164
+ + (if $tool == "" then {} else {tool:$tool} end)
165
+ + (if $reason == "" then {} else {reason:$reason} end)
166
+ + (if $agent == "" then {} else {agentId:$agent} end)' 2>/dev/null)" \
167
+ >> "$navori_audit_file" 2>/dev/null
168
+
169
+ return 0
170
+ }
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env bash
2
- # navori — audit-mode trigger (UserPromptSubmit)
2
+ # navori — audit-mode prompt recorder (UserPromptSubmit)
3
3
  #
4
- # Detects an audit-mode invocation in the user's prompt and asks Claude to
5
- # CONFIRM with the user before anything is recorded. It never activates the
6
- # mode by itself: a false positive must die in the question, leaving no state
7
- # on disk.
4
+ # While audit-mode is active, appends the typed prompt to the session's
5
+ # append-only log. That is its ONLY job.
8
6
  #
9
- # While the mode is active it appends the typed prompt to the session's
10
- # append-only log. Writes are O_APPEND only; the log is never re-read to be
11
- # rewritten, so parallel subagents cannot corrupt it and a crashed session
12
- # still leaves a valid (merely shorter) file.
7
+ # It used to also detect an audit-mode invocation in the prompt text and ask
8
+ # Claude to confirm activation. That was removed (spec 0013, R3): matching
9
+ # `audit mode` as a substring cannot separate INVOKING the mode from TALKING
10
+ # ABOUT it, and talking about it is what you do all day while working on the
11
+ # feature. The asymmetry made it worse — turning it ON matched loosely, while
12
+ # turning it OFF required the literal phrase, so sessions stayed open forever.
13
+ # Activation is now exclusively `navori audit --start <id>`.
14
+ #
15
+ # Writes are O_APPEND only; the log is never re-read to be rewritten, so
16
+ # parallel subagents cannot corrupt it and a crashed session still leaves a
17
+ # valid (merely shorter) file.
13
18
  #
14
19
  # FAIL-OPEN ABSOLUTE: this hook runs on every prompt. Any error, any missing
15
20
  # dependency, any odd path exits 0 silently. It must never be the reason a
@@ -17,34 +22,6 @@
17
22
 
18
23
  set +e
19
24
 
20
- emit_and_exit() { printf '%s\n' "$1"; exit 0; }
21
-
22
- # Is `audit` actually available in the CLI on PATH?
23
- #
24
- # The hook orders the agent to run `navori audit --start`, and that resolves the
25
- # PUBLISHED binary, never a working tree's build. When the installed version
26
- # predates the subcommand, citty prints the help and exits 0 — so an agent that
27
- # checks the exit code reads a silent no-op as success and reports a recording
28
- # that never started. Match the subcommand inside the CLI's own USAGE line
29
- # instead of trusting the status.
30
- #
31
- # Returns 1 when the subcommand is absent AND when the check itself cannot run
32
- # (no binary on PATH, no USAGE line). Both collapse into "could not confirm",
33
- # which is what the caller's message must say: claiming "your version is old"
34
- # would be wrong for a machine with no navori installed at all.
35
- audit_subcommand_available() {
36
- command -v navori >/dev/null 2>&1 || return 1
37
- usage=$(navori --help 2>/dev/null | grep -m1 '^USAGE' 2>/dev/null) || return 1
38
- [ -n "$usage" ] || return 1
39
- # Normalize separators so the token matches at any position: `USAGE navori
40
- # init|add|audit` -> `|USAGE|navori|init|add|audit|`.
41
- tokens=$(printf '%s' "$usage" | tr ' ' '|' 2>/dev/null) || return 1
42
- case "|$tokens|" in
43
- *"|audit|"*) return 0 ;;
44
- *) return 1 ;;
45
- esac
46
- }
47
-
48
25
  payload=$(cat 2>/dev/null) || exit 0
49
26
  [ -n "$payload" ] || exit 0
50
27
  command -v jq >/dev/null 2>&1 || exit 0
@@ -84,49 +61,20 @@ else
84
61
  fi
85
62
  log_file=$audits_root/$repo/session-$session_id.log
86
63
 
87
- lower=$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]' 2>/dev/null) || exit 0
88
-
89
- # Deliberately tolerant: a spurious match costs one question, while a missed
90
- # one costs the whole recording.
91
- case "$lower" in
92
- *"audit mode"*|*"audit-mode"*|*"modo audit"*|*"modo auditoría"*|*"modo auditoria"*) matched=1 ;;
93
- *) matched=0 ;;
94
- esac
95
-
96
- case "$lower" in
97
- *apaga*|*apagar*|*desactiva*|*"salir de"*|*detén*|*deten*|*stop*|*"turn off"*|*disable*) off_intent=1 ;;
98
- *) off_intent=0 ;;
99
- esac
100
-
101
- if [ -f "$log_file" ]; then
102
- # Active: record the human's own words — they entered the model's context,
103
- # so they cost tokens and belong in the audit.
104
- #
105
- # `transcript_path` rides along because the payload is the ONLY place it is
106
- # stated. Without it the reader has to guess the transcript's location by
107
- # re-deriving Claude Code's undocumented directory encoding (see paths.ts),
108
- # and a guess that misses costs the whole report.
109
- ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || ts=""
110
- transcript=$(printf '%s' "$payload" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript=""
111
- printf '%s\n' "$(jq -cn --arg ts "$ts" --arg ev "prompt" --arg p "$prompt" --arg tr "$transcript" \
112
- '{ts:$ts,event:$ev,prompt:$p} + (if $tr == "" then {} else {transcript:$tr} end)' 2>/dev/null)" >> "$log_file" 2>/dev/null
64
+ # Not marked not recording. This is also what makes the hook free outside
65
+ # audit-mode: one stat and out.
66
+ [ -f "$log_file" ] || exit 0
113
67
 
114
- if [ "$matched" = "1" ] && [ "$off_intent" = "1" ]; then
115
- if audit_subcommand_available; then
116
- emit_and_exit "[navori audit-mode] The user asked to turn audit-mode OFF. Before doing anything, ask them explicitly: \"audit mode will be turned off, continue?\". Only if they confirm, run: navori audit --stop $session_id (that seals the log and generates the report). Then check the output: it must name the report it wrote. If it prints the command list (USAGE) instead, the installed CLI has no such subcommand — tell the user and do NOT assume the session was closed. If they decline, leave the mode active and carry on with the task."
117
- else
118
- emit_and_exit "[navori audit-mode] The user asked to turn audit-mode OFF, but the available 'navori' could not be confirmed to ship the 'audit' subcommand: it may not be installed at all, or it may predate that command. Do NOT run 'navori audit --stop' blindly: if the binary exists but is old, it prints its help and exits 0, which looks like success without being one. Tell the user what happened and that the session log stays intact on disk, so the report can still be generated once the CLI is up to date. Then carry on with the task."
119
- fi
120
- fi
121
- exit 0
122
- fi
123
-
124
- if [ "$matched" = "1" ] && [ "$off_intent" = "0" ]; then
125
- if audit_subcommand_available; then
126
- emit_and_exit "[navori audit-mode] An audit-mode invocation was detected in the prompt. Before activating anything, ask the user explicitly: \"an audit mode invocation was detected, continue?\". Only if they confirm, run: navori audit --start $session_id (that creates the session log). Then check the output: it must name the log file it created. If it prints the command list (USAGE) instead, the installed CLI has no such subcommand — tell the user and do NOT assume the mode is active. If they decline, run nothing and carry on with the task."
127
- else
128
- emit_and_exit "[navori audit-mode] An audit-mode invocation was detected, but the available 'navori' could not be confirmed to ship the 'audit' subcommand: it may not be installed at all, or it may predate that command. Do NOT run 'navori audit --start' blindly: if the binary exists but is old, it prints its help and exits 0, which looks like success without being one. Tell the user that nothing was activated, and why. Then carry on with the task."
129
- fi
130
- fi
68
+ # Record the human's own words they entered the model's context, so they cost
69
+ # tokens and belong in the audit.
70
+ #
71
+ # `transcript_path` rides along because the payload is the ONLY place it is
72
+ # stated. Without it the reader has to guess the transcript's location by
73
+ # re-deriving Claude Code's undocumented directory encoding (see paths.ts),
74
+ # and a guess that misses costs the whole report.
75
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || ts=""
76
+ transcript=$(printf '%s' "$payload" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript=""
77
+ printf '%s\n' "$(jq -cn --arg ts "$ts" --arg ev "prompt" --arg p "$prompt" --arg tr "$transcript" \
78
+ '{ts:$ts,event:$ev,prompt:$p} + (if $tr == "" then {} else {transcript:$tr} end)' 2>/dev/null)" >> "$log_file" 2>/dev/null
131
79
 
132
80
  exit 0
@@ -30,7 +30,43 @@ set -euo pipefail
30
30
  # A missing JSON parser used to make this guard wave every command through; the
31
31
  # shared extractor falls back to sed so it still inspects the command.
32
32
  # navori:include extract-cmd
33
- [ -z "$cmd" ] && exit 0
33
+
34
+ navori_audit_name="guard-destructive"
35
+ navori_audit_phase="PreToolUse"
36
+ navori_audit_tool="Bash"
37
+ # Fallback no-ops, overwritten by the real definitions the include brings in.
38
+ # They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
39
+ # includes expanded — a raw copy of the asset, a render that half-finished — an
40
+ # undefined function would be exit 127, and under `set -e` that KILLS the hook.
41
+ # A recorder that can kill the thing it observes is the one bug this partial may
42
+ # never have.
43
+ navori_audit_begin() { :; }
44
+ navori_audit_log() { :; }
45
+ # navori:include audit-log
46
+ navori_audit_begin
47
+
48
+ # The verdict is resolved in a trap, NOT by a call at the end of the file.
49
+ #
50
+ # This hook's managed block ends before its `navori:user-section`, and the render
51
+ # only syncs what is INSIDE the block — so a call placed after it lives in the
52
+ # user's own territory and never reaches the mirror. That is exactly how the most
53
+ # critical hook in the harness ended up being the only one not recording.
54
+ #
55
+ # The trap also covers the extra guards a user writes in that section: whatever
56
+ # they add, the exit code still tells the truth about what happened.
57
+ navori_audit_verdict="allow"
58
+ navori_audit_reason=""
59
+ navori_audit_on_exit() {
60
+ navori_audit_log "$navori_audit_verdict" "$navori_audit_reason" || true
61
+ return 0
62
+ }
63
+ trap navori_audit_on_exit EXIT
64
+
65
+ if [ -z "$cmd" ]; then
66
+ navori_audit_verdict="skip"
67
+ navori_audit_reason="sin comando que inspeccionar"
68
+ exit 0
69
+ fi
34
70
 
35
71
  # branchBase is shell-quoted at render time via the shq: marker (#197): a
36
72
  # hostile branchBase in navori.config.json lands here as an inert literal,
@@ -43,6 +79,11 @@ block() {
43
79
  # should carry, and the reason line already says which rule fired.
44
80
  echo "[navori] command: ${cmd:0:2000}" >&2
45
81
  echo "[navori] if intentional, run the command yourself outside the agent." >&2
82
+ # Only ASSIGNMENTS here: this is the one place the guard says no, and nothing
83
+ # may come between the decision and `exit 2`. The recording happens in the
84
+ # trap, after the exit is already committed.
85
+ navori_audit_verdict="block"
86
+ navori_audit_reason="$1"
46
87
  exit 2
47
88
  }
48
89
 
@@ -633,6 +674,66 @@ if printf '%s' "$live" | grep -qE '(of=/dev/(sd|nvme|disk|hd)|>[[:space:]]*/dev/
633
674
  block "direct write to a block device"
634
675
  fi
635
676
 
677
+ # 6. Shell rewrites of a file navori MAINTAINS (#530). In auto mode every edit
678
+ # arrives as a shell command — `sed -i`, a heredoc, a `>` redirect — instead
679
+ # of the `Edit` tool, and the two fail very differently: `Edit` aborts when
680
+ # the old text doesn't match (a check that the agent understood the file),
681
+ # while `sed -i` with a pattern that matches nothing exits 0 and a `>` with
682
+ # the wrong path truncates the file. On a managed file the damage is the #523
683
+ # shape: the body changes, its `hash=` no longer matches, navori marks the
684
+ # block `user-modified` and STOPS UPDATING IT. Nothing announces that.
685
+ #
686
+ # These files are a MIRROR, and the harness already says so in prose ("los
687
+ # conflictos en `.claude/` y CLAUDE.md no se resuelven a mano: son espejo").
688
+ # This rule is that policy made executable. The way to change them is to edit
689
+ # the source asset and run `navori render --apply`, or `navori sync` to
690
+ # reconcile — neither of which matches here, because neither redirects.
691
+ #
692
+ # COVERED (blocked), by FORM of the write:
693
+ # > path · >| path truncating redirect (`>>` append is NOT: it adds
694
+ # after the blocks and invalidates no hash)
695
+ # sed -i … path in-place rewrite
696
+ # tee path overwrite (`tee -a` is an append, so it is not)
697
+ # …and by TARGET, the marker-carrying outputs of `ENGINE_OUTPUTS`
698
+ # (lib/health.ts), which is what "managed" means anywhere else in navori:
699
+ # CLAUDE.md · AGENTS.md · .claude/settings.json
700
+ # .claude/agents · .claude/skills · .claude/hooks
701
+ # .agents/skills · .codex/config.toml · .codex/agents · .codex/hooks
702
+ # .cursor/rules
703
+ #
704
+ # NOT COVERED, and each exclusion is load-bearing:
705
+ # .claude/progress/… and .codex/progress/… the agent handoff files, for
706
+ # BOTH engines. Every subagent ends by writing one, usually with a
707
+ # heredoc; blocking that breaks the harness's own protocol with the
708
+ # harness's own guard. They carry no markers, so nothing there can be
709
+ # invalidated. Naming `.codex/` wholesale was exactly that bug: it
710
+ # swallowed `.codex/progress/` and would have silenced every Codex
711
+ # subagent's handoff (#389's rule caught it).
712
+ # .claude/settings.local.json · .claude/worktrees/ machine-local, never
713
+ # rendered, never marker-managed.
714
+ # cp/mv INTO a managed path, `python -c` writes, `awk > file`, `perl -i`.
715
+ # Enumerating write verbs is the losing half of this fight — the same
716
+ # "describe the danger by its textual form" pattern the blind audit found
717
+ # eight times. The PostToolUse watcher is the half that doesn't care about
718
+ # form: it re-checks the hashes AFTER the fact, so anything that gets past
719
+ # this rule still surfaces. This rule is the seatbelt; that one is the net.
720
+ managed_dir='\.claude/(agents|skills|hooks)|\.agents/skills|\.codex/(agents|hooks)|\.cursor/rules'
721
+ managed_path="(CLAUDE\.md|AGENTS\.md|\.claude/settings\.json|\.codex/config\.toml|(${managed_dir})/[^[:space:];&|]+)"
722
+ # The redirect check reads `scan`, NOT `segments`: the split rewrites every `|`
723
+ # into a newline, so `>| CLAUDE.md` (forced clobber) would be torn in half and
724
+ # the target would land in a segment of its own. Reading the unsplit copy is
725
+ # safe HERE and nowhere else in this file, because the pattern is local — only
726
+ # whitespace may sit between the `>` and its target, so it cannot reach across a
727
+ # `&&` into another command the way rules 1-3 could.
728
+ # `-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*` accepts the backup-suffix spellings that
729
+ # are the everyday form on both platforms: GNU `sed -i.bak`, BSD `sed -i ''`.
730
+ # Missing them would have left the rule covering the tutorial spelling only.
731
+ if printf '%s' "$scan" | grep -qE "(^|[^>])>\|?[[:space:]]*(\./)?${managed_path}([[:space:]]|\$)" \
732
+ || printf '%s' "$segments" | grep -qE "(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*|--in-place)([[:space:]]|=).*${managed_path}" \
733
+ || printf '%s' "$segments" | grep -qE "(^|[[:space:]])tee[[:space:]]+([^-][^[:space:]]*[[:space:]]+)*(\./)?${managed_path}([[:space:]]|\$)"; then
734
+ block "shell rewrite of a navori-managed file — edit the source asset and run 'navori render --apply' (or 'navori sync'); a direct write invalidates the block hash and freezes it"
735
+ fi
736
+
636
737
  # navori:user-section
637
738
  # user: add extra guards here. `$cmd` already holds the full command (compound
638
739
  # commands included) and `block "<reason>"` aborts with exit 2.
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # PostToolUse(Bash) watcher for managed-block drift (#530).
4
+ #
5
+ # THIS HOOK NEVER READS THE COMMAND. That is the whole design. Its sibling
6
+ # `guard-destructive.sh` decides by the SHAPE of what you typed, so it only ever
7
+ # covers the write verbs someone enumerated — `>`, `sed -i`, `tee` — and misses
8
+ # `python -c`, `perl -i`, `awk > file`, a formatter, or a script the agent
9
+ # didn't write. The blind audit found that "describe the danger by its textual
10
+ # form" pattern eight times, four of them with a test pinning the hole shut.
11
+ #
12
+ # So this one asks the only question that has no form: AFTER the command ran,
13
+ # do navori's managed blocks still hash to what their markers claim? A block
14
+ # whose body changed is a block navori will refuse to update from now on
15
+ # (`user-modified-skipped`) — the #523 freeze, arriving in silence. Whatever
16
+ # rewrote it, legitimately or not, this notices.
17
+ #
18
+ # The managed block below is regenerated by `navori render` and must NOT be
19
+ # edited by hand.
20
+ #
21
+ # SCOPE. settings.json invokes hooks as `bash "$CLAUDE_PROJECT_DIR/.claude/…"`,
22
+ # so this process starts in the MAIN repo even when the command ran inside an
23
+ # agent worktree (#454). Checking the main mirror is the right default — that is
24
+ # the copy every session shares and the one a stray write actually freezes — but
25
+ # a block broken INSIDE a worktree is not seen here. It surfaces when that branch
26
+ # is rendered or reviewed.
27
+ #
28
+ # COST, and why this compares CONTENT rather than mtimes. The obvious cheap
29
+ # check is `find -newer <stamp>`, and it is wrong here: `find` compares mtimes at
30
+ # whatever resolution the filesystem stores, which on several (ext4 under the CI
31
+ # runner among them) is ONE SECOND. Two writes inside the same second as the
32
+ # stamp are invisible — the watcher would report the first and silently miss the
33
+ # second, which is precisely the failure it exists to prevent. It passed on APFS
34
+ # and failed in CI, so the clock was never a sound basis.
35
+ #
36
+ # So the common case costs one `shasum` pass over the managed files (~25ms
37
+ # measured over 60 files) and compares that list against the previous one. It is
38
+ # exact, it depends on no clock, and per-block hashing — the expensive part, ~3.5s
39
+ # for 53 blocks — runs ONLY for the files whose content actually changed, which
40
+ # in a normal session is none and in a bad one is one.
41
+ set -uo pipefail
42
+
43
+ # PostToolUse delivers its payload on stdin; this hook never needed it and the
44
+ # audit recorder does (session_id/cwd), so it is captured rather than ignored.
45
+ payload=$(cat 2>/dev/null) || payload=""
46
+
47
+ navori_audit_name="managed-drift-watch"
48
+ navori_audit_phase="PostToolUse"
49
+ navori_audit_tool="Bash"
50
+ # Fallback no-ops, overwritten by the real definitions the include brings in.
51
+ # They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
52
+ # includes expanded — a raw copy of the asset, a render that half-finished — an
53
+ # undefined function would be exit 127, and under `set -e` that KILLS the hook.
54
+ # A recorder that can kill the thing it observes is the one bug this partial may
55
+ # never have.
56
+ navori_audit_begin() { :; }
57
+ navori_audit_log() { :; }
58
+ # navori:include audit-log
59
+ navori_audit_begin
60
+
61
+ # This hook has SEVEN early exits (no sha, no roots, no changes, …). Wiring a
62
+ # call into each is how the set drifts the next time one is added, so the verdict
63
+ # is derived once, from the exit code, in a trap.
64
+ #
65
+ # `navori_audit_reached_check` is what separates "ran and found nothing" from
66
+ # "bailed before checking anything" — the distinction the whole exercise exists
67
+ # for. Without it every early exit would report `clean`, i.e. would claim a
68
+ # verification that never happened.
69
+ navori_audit_reached_check=0
70
+ navori_audit_on_exit() {
71
+ navori_audit_code=$?
72
+ if [ "$navori_audit_code" -eq 2 ]; then
73
+ navori_audit_log "dirty" "bloque managed con hash desalineado"
74
+ elif [ "$navori_audit_reached_check" -eq 1 ]; then
75
+ navori_audit_log "clean"
76
+ else
77
+ navori_audit_log "skip" "sin nada que verificar"
78
+ fi
79
+ return 0
80
+ }
81
+ trap navori_audit_on_exit EXIT
82
+
83
+ cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0
84
+
85
+ stamp=".claude/.managed-drift-stamp"
86
+
87
+ # sha1 tool, resolved once. `shasum` on macOS, `sha1sum` on most Linuxes; both
88
+ # print `<hash> <path>` for a file list, which is the format the stamp stores.
89
+ sha=""
90
+ command -v shasum >/dev/null 2>&1 && sha="shasum -a 1"
91
+ [ -z "$sha" ] && command -v sha1sum >/dev/null 2>&1 && sha="sha1sum"
92
+ # No sha tool: stay silent. This is a detector, not a gate — the guard still
93
+ # sits in front, and a noisy failure here would fire on every single command.
94
+ [ -z "$sha" ] && exit 0
95
+
96
+ # The marker-carrying outputs of `ENGINE_OUTPUTS` (lib/health.ts) — the same
97
+ # list the guard's rule 6 protects, and for the same reason.
98
+ #
99
+ # `.claude/progress/` and `.codex/progress/` are deliberately absent for BOTH
100
+ # engines: those are agent handoff files, they carry no markers, and every
101
+ # subagent writes one — including them would re-hash a fresh report on every
102
+ # command and find nothing. `.claude/worktrees/` too: each is a full checkout,
103
+ # so walking it would make this hook cost more than the command it follows. That
104
+ # is why `.codex` is NOT listed wholesale.
105
+ roots=""
106
+ for r in CLAUDE.md AGENTS.md .claude/settings.json .claude/agents .claude/skills .claude/hooks .agents/skills .codex/config.toml .codex/agents .codex/hooks .cursor/rules; do
107
+ [ -e "$r" ] && roots="$roots $r"
108
+ done
109
+ [ -z "$roots" ] && exit 0
110
+
111
+ # shellcheck disable=SC2086 — word splitting is how the root list is passed.
112
+ current=$(find $roots -type f -exec $sha {} + 2>/dev/null | sort || true)
113
+ [ -z "$current" ] && exit 0
114
+
115
+ # First run in a session (or after the ephemeral dir was wiped): adopt the
116
+ # current state as the baseline and say nothing. Reporting every block on the
117
+ # first command would train the reader to ignore this hook by lunchtime.
118
+ if [ ! -f "$stamp" ]; then
119
+ mkdir -p .claude 2>/dev/null || exit 0
120
+ printf '%s\n' "$current" > "$stamp" 2>/dev/null || true
121
+ exit 0
122
+ fi
123
+
124
+ # Lines present now but not in the baseline: a file whose CONTENT changed, or a
125
+ # new one. A deleted file appears only in the baseline and is ignored — there is
126
+ # no block left to verify.
127
+ changed=$(printf '%s\n' "$current" | grep -F -x -v -f "$stamp" 2>/dev/null | sed -E 's/^[a-f0-9]+[[:space:]]+//' || true)
128
+ # Re-baseline BEFORE reporting, so one write is reported once instead of on
129
+ # every command for the rest of the session. A second write does change the
130
+ # content again, and does get its own report.
131
+ printf '%s\n' "$current" > "$stamp" 2>/dev/null || true
132
+ [ -z "$changed" ] && exit 0
133
+
134
+ # From here on the work is per-CHANGED-file only. `$(…)` strips the trailing
135
+ # newline, which is what makes these hashes agree with `computeManagedHash`
136
+ # (lib/marker.ts: sha1 over the body with trailing whitespace removed, first 8
137
+ # hex); without it every hash differs and the hook cries wolf on healthy files.
138
+ drift=""
139
+ while IFS= read -r file; do
140
+ [ -n "$file" ] || continue
141
+ # Both marker syntaxes (lib/marker.ts): HTML for markdown, `#` for scripts and
142
+ # TOML. Anchored at the start of the line so a block QUOTED inside prose —
143
+ # this file's own header, a skill that documents the format — is not read as a
144
+ # real marker.
145
+ while IFS='|' read -r id declared; do
146
+ [ -n "$id" ] || continue
147
+ body=$(awk -v id="$id" '
148
+ $0 ~ ("^<!-- navori:managed id=\"" id "\"") { f=1; next }
149
+ $0 ~ ("^# navori:managed start id=\"" id "\"") { f=1; next }
150
+ $0 ~ ("^<!-- /navori:managed id=\"" id "\"") { f=0 }
151
+ $0 ~ ("^# navori:managed end id=\"" id "\"") { f=0 }
152
+ f' "$file")
153
+ actual=$(printf '%s' "$body" | $sha | cut -c1-8)
154
+ [ "$actual" = "$declared" ] || drift="${drift}
155
+ ${file} block '${id}' (marker says ${declared}, content hashes ${actual})"
156
+ done <<EOF
157
+ $(grep -oE '^(<!-- navori:managed|# navori:managed start) id="[^"]+" hash="[a-f0-9]+"' "$file" 2>/dev/null \
158
+ | sed -E 's/.*id="([^"]+)" hash="([a-f0-9]+)".*/\1|\2/')
159
+ EOF
160
+ done <<EOF
161
+ $changed
162
+ EOF
163
+
164
+ navori_audit_reached_check=1
165
+ [ -z "$drift" ] && exit 0
166
+
167
+ # Exit 2 so the text reaches the model rather than scrolling past in a log: the
168
+ # whole failure mode being cured is that this goes unnoticed. The command has
169
+ # already run — nothing is being reverted or retried.
170
+ cat >&2 <<MSG
171
+ navori: a managed block no longer matches its marker hash.${drift}
172
+
173
+ navori will now treat those blocks as hand-edited and STOP updating them (the
174
+ #523 freeze). Nothing reverted this for you.
175
+
176
+ Recover with 'navori sync' (reconciles, shows the conflict diff) or
177
+ 'navori render --apply' if the block should simply be regenerated. If the edit
178
+ was intentional, it belongs in the source asset, not in the rendered mirror.
179
+ MSG
180
+ exit 2
@@ -21,14 +21,53 @@
21
21
  # The managed block is regenerated by `navori render`; do NOT edit by hand.
22
22
  set -euo pipefail
23
23
 
24
- cat >/dev/null 2>&1 || true # drain the PreCompact JSON on stdin (unused)
24
+ # The PreCompact JSON was drained and discarded here; it is kept now because the
25
+ # audit recorder reads `session_id`/`cwd` out of it. Draining is still the point:
26
+ # an undrained stdin can leave the host writing into a closed pipe.
27
+ payload=$(cat 2>/dev/null) || payload=""
28
+
29
+ navori_audit_name="precompact-session-summary"
30
+ navori_audit_phase="PreCompact"
31
+ # Fallback no-ops, overwritten by the real definitions the include brings in.
32
+ # They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
33
+ # includes expanded — a raw copy of the asset, a render that half-finished — an
34
+ # undefined function would be exit 127, and under `set -e` that KILLS the hook.
35
+ # A recorder that can kill the thing it observes is the one bug this partial may
36
+ # never have.
37
+ navori_audit_begin() { :; }
38
+ navori_audit_log() { :; }
39
+ # navori:include audit-log
40
+ navori_audit_begin
41
+
42
+ # The verdict is a VARIABLE resolved in a trap, not a call per branch. These
43
+ # hooks have several early exits each (no git, no worktrees, nothing to inject),
44
+ # and wiring a call into every one is how the set drifts the next time somebody
45
+ # adds an exit. Defaulting to `skip` makes a new early exit semantically correct
46
+ # for free: it means "ran, decided it had nothing to do", which is exactly what
47
+ # an unhandled early return is.
48
+ navori_audit_verdict="skip"
49
+ navori_audit_reason=""
50
+ navori_audit_on_exit() {
51
+ navori_audit_log "$navori_audit_verdict" "$navori_audit_reason" || true
52
+ return 0
53
+ }
54
+ trap navori_audit_on_exit EXIT
55
+
25
56
 
26
57
  ctx="A punto de compactar el contexto: el detalle turno-a-turno se resumirá. Antes de perderlo, persiste un resumen de sesión con tu herramienta de memoria (engram: guarda el resumen de sesión) y/o anota las decisiones y los bugs con causa raíz de esta sesión en progress/current.md."
27
58
 
28
59
  # Emit additionalContext safely: node (best escaping) → jq → give up (exit 0).
29
60
  if command -v node >/dev/null 2>&1; then
30
61
  CTX="$ctx" node -e 'process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"PreCompact",additionalContext:process.env.CTX}}))'
62
+ navori_audit_verdict="inject"
31
63
  elif command -v jq >/dev/null 2>&1; then
32
64
  jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreCompact",additionalContext:$ctx}}'
65
+ navori_audit_verdict="inject"
66
+ else
67
+ # Neither serializer available: the reminder never reached the model. Recorded
68
+ # as `noop` rather than `inject`, because the audit must not claim a context
69
+ # injection that did not happen.
70
+ navori_audit_verdict="noop"
71
+ navori_audit_reason="sin node ni jq: el recordatorio no se emitio"
33
72
  fi
34
73
  exit 0