SLOPSHOPPER

navigator

Finish What You Start - Sessions that last, AI that learns, Features that ship. Context engineering + Theory of Mind + Knowledge Graph + Loop Mode + Task Mode…

newpanebandguardcommandtoast
★ 355v8.3.5MITupdated 2026-10-08qf-studio/navigator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · navigator
│ ┃ Navigator ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ context │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ 49% ██████████████████████▓░░░░░░░░░░░░… │ ⏺ Update(src/auth.ts) │ ┃ │ compact safe │ ⎿ Added 2 lines, removed 1 line │ ┃ │ … │ ⏺ Bash(bun test) │ ┃ │ … │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ session │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ $0.42 5h 31% │ │ ┃ │ phase — │ › /nav │ ┃ │ graph — │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ next │ │ ┃ │ │ │ ┃ │ no destination · say what you're │ │ ┃ │ building, or mark a task in progress │ │ ┃ │ → next Done. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ memories │ │ ┃ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Navigator
╭──────────────────────────────────────────────────────────╮ │ context │ │ │ │ 49% ██████████████████████▓░░░░░░░░░░░░░░░░░░░░░░░░ │ │ compact safe │ │ │ │ ⢸ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ session │ │ │ │ $0.42 5h 31% │ │ phase — │ │ graph — │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ next │ │ │ │ no destination · say what you're building, or mark a │ │ task in progress │ │ → next Done. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ memories │ │ │ │ none for the open tasks │ │ ▸ pins a memory into your next prompt │ ╰──────────────────────────────────────────────────────────╯ m: marker · c: compact · r: refresh · d: details ·
README

Navigator

Finish What You Start

Sessions that last. AI that learns. Features that ship.

MIT License Version


What is Navigator?

Navigator is a Claude Code plugin that implements context engineering for AI-assisted development.

Instead of loading all your documentation upfront (and crashing at exchange 7), Navigator loads what you need, when you need it—keeping 94% of your context window available for actual work.

For developers using Claude Code who want sessions that finish features instead of crash mid-way.


The Loop You're Stuck In

You know the pattern:

Exchange 5:  Claude forgets your recent changes
Exchange 7:  Hallucinations start ("that function doesn't exist")
Exchange 8:  "Context limit reached"

Restart. Reload. Re-explain everything. Repeat.

You loaded 150k tokens of documentation "just in case."

You used 8k.

The rest? Noise drowning out signal.


Break The Loop

Navigator implements context engineering—load what you need, when you need it.

MetricWithout NavigatorWith Navigator
Tokens loaded150,00012,000
Session length5-7 exchanges20+ exchanges
Context at end95% (crashed)35% (comfortable)
Token savings—92%

Result: Sessions that actually finish what they start.


And Your AI Gets Smarter

Navigator v5.0 adds Theory of Mind—Claude learns you.

nav-profile: Remembers your preferences across sessions

"Remember I prefer concise explanations"
→ Applied in future sessions
→ Auto-learns from corrections

nav-diagnose: Catches collaboration drift

Same correction twice → Quality check triggered
"You're not getting this" → Re-anchoring prompt

Loop Mode (v5.1): Run until done

"Run until done: add user authentication"
→ Structured completion with progress tracking
→ Dual-condition exit (heuristics + explicit signal)
→ Stagnation detection prevents infinite loops

Knowledge Graph (v6.0): Project memory that persists

"What do we know about auth?"
→ Unified search across tasks, SOPs, system docs
→ Experiential memory: patterns, pitfalls, decisions
→ Auto-surfaces relevant context on session start

Reply modes (v7.9): the reply shaped for you, switched by a sentence

"adhd mode on"
→ One next action first, bullets over prose, lists capped at five
→ Follows you across repos; "adhd mode off" ends it
→ STE mode (one idea per sentence, active voice) ships in v8.3.0

See Where You Are

Since v8 the workflow is enforced by a runtime that runs inside Claude Code, not by prose in a config file. It gates task-shaped prompts, guards fan-out reads, records what each turn finished, and writes one line to a reject log every time it says no. It also draws.

The /nav pane

Type /nav and a pane opens beside your session:

CardWhat it tells you
contextFill percentage, trend, and whether this is a good moment to compact
sessionToday's cost, tokens, cache hit rate, tokens/min
readsFan-out verdict and today's reject count
judgeWhat the typed judge decided about your last prompt, and what Navigator did
nextThe current leg of the active task as a button that submits it, with an ETA
memoriesKnowledge-graph memories for the open task; pin one into your next prompt

A one-line status band above the prompt shows the phase, context fill and next action without opening the pane. Requires Claude Code 2.1.287 or newer; older versions run the same behaviors through the Python fallback, without the pane.


Same Workflows. More Capabilities.

Navigator is a superset. Everything you'd expect, plus context engineering.

FeatureNavigatorOthers
Structured workflows✅ Full skill suite✅
Component generation✅✅
Test generation✅✅
Session longevity20+ exchanges5-7 exchanges
Token savings92% verifiedNone
Code simplification✅❌
Task Mode✅❌
Theory of Mind✅❌
Knowledge Graph✅❌
TRIZ divergent solving✅❌
Web deep research (cited, gate-checked)✅❌
Loop mode✅❌
Runtime-enforced workflow (in-process mod + Python fallback)✅❌
Live /nav pane and status band✅❌
Reject log (every refusal, one JSON line)✅❌
Reply modes (ADHD, STE)✅❌
OpenTelemetry metrics✅❌
Figma MCP integration✅❌

Same foundation. Superior context management.


Proof, Not Promises

Not estimates. Verified via OpenTelemetry.

╔══════════════════════════════════════════════════════╗
║          NAVIGATOR EFFICIENCY REPORT                 ║
╚══════════════════════════════════════════════════════╝

📊 TOKEN USAGE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Your project documentation:     150,000 tokens
Loaded this session:             12,000 tokens
Tokens saved:                   138,000 tokens (92% ↓)

📈 SESSION METRICS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Context usage:                        35% (excellent)
Efficiency score:                  94/100 (excellent)

Check yours: type /nav for the live pane, or say "show my stats".


Quick Start

Install

# Claude Code plugin marketplace
/plugin marketplace add qf-studio/navigator
/plugin install navigator

# Restart Claude Code (2.1.287+ for the in-process runtime and the /nav pane)

Code intelligence (optional). Install a language-server plugin for your stack (/plugin install pyright-lsp@claude-plugins-official, typescript-lsp, gopls-lsp, …; the server binary must be on your PATH) and the research agent answers symbol questions with go-to-definition, references and outlines instead of text search. Without one it uses Grep exactly as before. Measured on this repo: a who-calls question went from 9 requests and 284k context tokens to 5 requests and 142k, with the complete call-site list.

Initialize

"Initialize Navigator in this project"

Start Every Session

"Start my Navigator session"

That's it. Navigator handles the rest.


Optional: Typed Prompt Judge

Navigator's loop-trigger, complexity and ambiguity detectors are keyword matchers. With a TypeSafe key they get a typed judge behind them (v7.7.0): one request per prompt, decisive answers override, everything else falls back to the keywords. Measured on 60 labeled prompts, tier accuracy went from 38 to 53. Off by default because the prompt text is sent to the API.

export TYPESAFE_API_KEY=...      # or: write it to ~/.config/typesafe/api_key (chmod 600)

Then say enable judge and verify with python3 hooks/nav_hook_lib/judge.py --check from the plugin root. Setup and tuning: .agent/sops/integrations/typesafe-judge-setup.md.


What You Get

Skills that auto-invoke on natural language:

"Start my Navigator session"              → Session with 92% savings
"Create a React component for profile"    → Component + tests + styles
"Add an API endpoint for posts"           → Endpoint + validation + tests
"Create context marker: checkpoint"       → 97% context compression
"Run until done: add dark mode"           → Loop mode completion
"Deep research on WebGPU browser support"  → Cited report + graph memories
"Enable judge"                             → Typed Jev judgments behind the prompt gates
"adhd mode on"                             → Replies shaped for you, across repos
/nav                                       → Live pane: context, cost, rejects, next leg

No commands to memorize. Skills detect intent and execute.


Stop Restarting. Start Shipping.

Your sessions crash because context management is broken by default.

Navigator fixes this with context engineering—the same principles Anthropic recommends.

92% token savings. 20+ exchange sessions. Verified metrics.

/plugin marketplace add qf-studio/navigator
/plugin install navigator

Finish what you start.


Troubleshooting

Skill loads but prints docs instead of executing, or /nav is missing

claude plugin update navigator@navigator-marketplace

Then restart Claude Code. Outdated plugin version, or the pane needs 2.1.287+.


Links

License

MIT License - See LICENSE

Source 53 files
hooks/mod/register.tsx 949 lines
1// Navigator mod (v8 runtime, TASK-84): status band, /nav pane, ADHD-mode injection.
2//
3// Spike for the v8 runtime question (see memory project-claude-code-mods-assessment).
4// Owned ops are announced through NAVIGATOR_MOD_OWNS (op names); runtime._dispatch skips
5// them. Unloaded, the Python ops keep doing the same job.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type {
11  NavActivity, NavDestination, NavHistory, NavJudge, NavOffRoute, NavPace, NavPane, NavReads, NavRejects,
12  NavStatus, NavTrip, NavUsage, NavWaypointClock,
13} from '../../types'
14import { personalDir } from './lib/reply_modes'
15import { redactSecrets } from './lib/judge'
16import { JUDGE_DEFAULTS } from './lib/gen/judge-data.gen'
17import { readJson } from './lib/config'
18import { makeCtx } from './lib/context'
19import { surfaceHealth } from './lib/life-health'
20import { getPath, isPilotExecutor, loadConfig } from './lib/config'
21import { RELEASES_URL, dueForCheck, latestStable, releasesViaCurl, updateNotice, updateSettings } from './lib/update'
22import type { Io, OpCtx } from './lib/types'
23import { projectRoot, run } from './lib/project'
24import { EVENT_OPS } from './ops'
25import { announce } from './owns'
26import { runEvent } from './runner'
27import type { Judgment } from './lib/scoring'
28import {
29  JUDGE_LABELS_FILE, JUDGE_TRAIL_MAX, NO_PACE, NO_READS, bashReadFiles, bashTouchesDocs, countRead, endTurnReads, etaText,
30  fanOutText, isDocPath, judgeTally, judgeView, labelEntry, latestMarker, parseGraphStats, parseMemories,
31  paneLayout, parseRejects, parseTasks, rateKind, recordPace, rejectsLast, rejectsLine, tokensOf, trailLine, turnsTo, withLabel,
32} from './ui/nav'
33import { REJECTS_PATH } from './lib/rejects'
34import { bashReadonly } from './lib/stop-bash'
35import { isInProgress } from './lib/tasks'
36import {
37  arrived, bandText, buildRoute, captureGoal, contentWords, currentWaypoint, detourTopic, isOffRoute,
38  nextTaskNumber, parkedTaskDoc, parseSteps, slugOf,
39} from './ui/route'
40import type { Step, Waypoint } from './ui/route'
41import { statusOf } from './ui/status'
42import {
43  PER_MINUTE, PER_MINUTE_SPAN_SEC, PER_MINUTE_STEP_SEC, buildTrip, parseMatrix, parseVector,
44  isLoopback, secondsSinceMidnight, tripQueries,
45} from './ui/trip'
46import { PALETTE, areaColors, brailleArea, compact, gauge, isFlat, percentColor } from './ui/palette'
47
48const PLUGIN = 'navigator'
49const PANE = 'nav'
50const NO_ACTIVITY: NavActivity = {
51  docsBytes: 0, docsReads: 0, agentRuns: 0, agentTokens: 0, committed: false,
52  lastTurnCommitted: false, docsTouched: false, bashCalls: 0, bashMutating: false,
53}
54const NO_HISTORY: NavHistory = { ctx: [], saved: [] }
55const EMPTY_NAV: NavPane = {
56  tasks: [], marker: null, graph: null, docsTreeBytes: 0, memories: [],
57}
58const STATE_FILE = '.agent/.nav-runtime-state.json'
59const DOC_DIRS = '.agent/DEVELOPMENT-README.md .agent/tasks .agent/system .agent/sops .agent/philosophy'
60
61const status = atom({ plugin: 'navigator', key: 'status' } as const, null as NavStatus | null)
62const pane = atom({ plugin: 'navigator', key: 'pane' } as const, null as NavPane | null)
63const activity = atom({ plugin: 'navigator', key: 'activity' } as const, NO_ACTIVITY)
64const usage = atom({ plugin: 'navigator', key: 'usage' } as const, null as NavUsage | null)
65const history = atom({ plugin: 'navigator', key: 'history' } as const, NO_HISTORY)
66const destination = atom({ plugin: 'navigator', key: 'destination' } as const, null as NavDestination | null)
67const offRoute = atom({ plugin: 'navigator', key: 'offRoute' } as const, null as NavOffRoute | null)
68const waypointClock = atom({ plugin: 'navigator', key: 'waypointClock' } as const, null as NavWaypointClock | null)
69const trip = atom({ plugin: 'navigator', key: 'trip' } as const, null as NavTrip | null)
70const reads = atom({ plugin: 'navigator', key: 'reads' } as const, NO_READS)
71const judge = atom({ plugin: 'navigator', key: 'judge' } as const, null as NavJudge | null)
72const showJudge = atom({ plugin: 'navigator', key: 'showJudge' } as const, false)
73// The last judged prompts, newest first (TASK-86): the trail behind `j`, labeled with y / x.
74const judgeTrail = atom({ plugin: 'navigator', key: 'judgeTrail' } as const, [] as NavJudge[])
75// `d`: the readouts (reads card, task list) that are not a surprise or a press.
76const showDetails = atom({ plugin: 'navigator', key: 'showDetails' } as const, false)
77const rejects = atom({ plugin: 'navigator', key: 'rejects' } as const, null as NavRejects | null)
78const showRejects = atom({ plugin: 'navigator', key: 'showRejects' } as const, false)
79const pace = atom({ plugin: 'navigator', key: 'pace' } as const, NO_PACE)
80// A memory the user pinned in the pane; rides the next prompt as context, then clears.
81const pinned = atom({ plugin: 'navigator', key: 'pinned' } as const, null as string | null)
82
83// $.state survives a hot reload, so a value written by an older version of this module
84// can lack fields added since. Read through these to merge stored values over defaults.
85const disowned = atom({ plugin: 'navigator', key: 'disowned' } as const, [] as string[])
86const crashes = atom({ plugin: 'navigator', key: 'crashes' } as const, {} as Record<string, number>)
87
88/**
89 * The mods API narrowed for imported modules (lib/, ops/, owns, runner). The static scan
90 * follows `$` only within this file, so every capability is a direct `$.noun.method` here.
91 */
92const ioOf = ($: EngineInterface): Io => ({
93  pluginRoot: $.plugin.root,
94  read: async path => String(await $.fs.read(path)),
95  write: async (path, text) => {
96    await $.fs.write(path, text)
97  },
98  exists: path => $.fs.exists(path),
99  list: async path =>
100    (await $.fs.list(path)).map(f => ({ name: f.name, mtimeMs: f.mtimeMs, kind: f.kind })),
101  run: async (argv, cwd, timeoutMs) => {
102    const r = await $.process.run(argv, { cwd, timeoutMs })
103    return { exitCode: r.exitCode, stdout: r.stdout }
104  },
105  stat: async path => {
106    try {
107      const st = await $.fs.stat(path)
108      return { kind: st.kind, mtimeMs: st.mtimeMs }
109    } catch {
110      return null
111    }
112  },
113  localOffsetMinutes: ms => -new Date(ms).getTimezoneOffset(),
114  runCapture: async (argv, cwd, timeoutMs) => {
115    const r = await $.process.run(argv, { cwd, timeoutMs })
116    return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
117  },
118  cwd: () => $.session.cwd(),
119  sessionId: async () => (await $.session.id()) || null,
120  nowMs: () => $.clock.now(),
121  version: async () => {
122    const v = await $.session.version()
123    return v.base === undefined ? { version: v.version } : { version: v.version, base: v.base }
124  },
125  env: async () => ({
126    PILOT_EXECUTOR: await $.env.get('PILOT_EXECUTOR'),
127    NAVIGATOR_CONFIG_HOME: await $.env.get('NAVIGATOR_CONFIG_HOME'),
128    XDG_CONFIG_HOME: await $.env.get('XDG_CONFIG_HOME'),
129    HOME: await $.env.get('HOME'),
130    TYPESAFE_API_KEY: await $.env.get('TYPESAFE_API_KEY'),
131  }),
132  setOwned: value => $.env.set('NAVIGATOR_MOD_OWNS', value),
133  http: async (url, init) => {
134    const r = await $.http.fetch(url, init)
135    return { ok: r.ok, status: r.status, text: r.text }
136  },
137  sleep: ms => $.clock.sleep(ms),
138  runWith: async (argv, init) => {
139    const r = await $.process.run(argv, init)
140    return { exitCode: r.exitCode, stdout: r.stdout }
141  },
142  disowned: async () => (await read($, disowned)) ?? [],
143  noteCrash: async op => {
144    await update($, crashes, c => ({ ...(c ?? {}), [op]: ((c ?? {})[op] ?? 0) + 1 }))
145    return ((await read($, crashes)) ?? {})[op] ?? 0
146  },
147  disown: async op => {
148    await update($, disowned, d => [...new Set([...(d ?? []), op])])
149  },
150})
151
152const readPane = async ($: EngineInterface): Promise<NavPane> =>
153  ({ ...EMPTY_NAV, ...((await read($, pane)) ?? {}) })
154const readHistory = async ($: EngineInterface): Promise<NavHistory> => {
155  const h = await read($, history)
156  return {
157    ctx: Array.isArray(h?.ctx) ? h.ctx : [],
158    saved: Array.isArray(h?.saved) ? h.saved : [],
159  }
160}
161const readActivity = async ($: EngineInterface): Promise<NavActivity> =>
162  ({ ...NO_ACTIVITY, ...((await read($, activity)) ?? {}) })
163
164/** Collect what the pane shows: open tasks, the active task's steps, memories, newest marker, graph size. */
165/** TASK-88: one fs.read of the reject log; absent file = empty log. */
166const refreshRejects = async ($: EngineInterface, root: string): Promise<void> => {
167  const text = await $.fs.read(`${root}/${REJECTS_PATH}`).then(String).catch(() => '')
168  const now = Date.now()
169  await update($, rejects, () => parseRejects(text, now))
170}
171
172/**
173 * `path|# TASK-80: Title` lines for every task doc whose Status line says in progress
174 * (TASK-91: the Status line decides, not a prose mention anywhere in the doc).
175 */
176const inProgressTaskLines = async ($: EngineInterface, root: string): Promise<string> => {
177  const dir = `${root}/.agent/tasks`
178  const names = (await $.fs.list(dir).catch(() => []))
179    .map(f => f.name)
180    .filter(n => n.endsWith('.md') && !n.toUpperCase().startsWith('README'))
181    .sort()
182  const lines: string[] = []
183  for (const name of names) {
184    const text = await $.fs.read(`${dir}/${name}`).then(String).catch(() => '')
185    if (!isInProgress(text)) continue
186    const heading = text.split('\n').find(l => l.startsWith('# ')) ?? ''
187    lines.push(`.agent/tasks/${name}|${heading}`)
188  }
189  return lines.join('\n')
190}
191
192const refreshPane = async ($: EngineInterface): Promise<void> => {
193  const root = await projectRoot(ioOf($))
194  if (root === null) {
195    await update($, pane, () => EMPTY_NAV)
196    return
197  }
198  await refreshRejects($, root).catch(() => {})
199  const functions = `${$.plugin.root}/skills/nav-graph/functions`
200  const graphPath = '.agent/knowledge/graph.json'
201  const tasks = await inProgressTaskLines($, root)
202  const stats = await run(ioOf($), ['python3', `${functions}/graph_manager.py`, '--action', 'stats',
203    '--graph-path', graphPath], root)
204  const memories = await run(ioOf($), ['python3', `${functions}/memory_recall.py`, '--auto',
205    '--agent-dir', '.agent', '--graph-path', graphPath, '--limit', String(MEMORY_LINES), '--format', 'compact'], root)
206  const markers = await $.fs.list(`${root}/.agent/.context-markers`).catch(() => [])
207  const treeBytes = Number((await run(ioOf($), ['sh', '-c',
208    `find ${DOC_DIRS} -name '*.md' -type f -exec cat {} + 2>/dev/null | wc -c`], root)).trim()) || 0
209  const taskList = parseTasks(tasks)
210  const active = taskList[taskList.length - 1]
211  let steps: Step[] = []
212  if (active?.path) {
213    try {
214      steps = parseSteps(String(await $.fs.read(`${root}/${active.path}`)))
215    } catch {
216      steps = []
217    }
218  }
219  await update($, pane, () => ({
220    tasks: taskList,
221    steps,
222    marker: latestMarker(markers),
223    graph: parseGraphStats(stats),
224    docsTreeBytes: treeBytes,
225    memories: parseMemories(memories),
226  }))
227}
228
229/**
230 * TASK-89: the cheap half of a refresh — re-parse the active task's steps so the next card
231 * moves while the turn is still running. One fs.read, no subprocess; the full refreshPane
232 * (task list, marker, memories) waits for the turn to end.
233 */
234const refreshSteps = async ($: EngineInterface, root: string): Promise<void> => {
235  const p = await readPane($)
236  const active = p.tasks[p.tasks.length - 1]
237  if (!active?.path) return
238  let steps: Step[]
239  try {
240    steps = parseSteps(String(await $.fs.read(`${root}/${active.path}`)))
241  } catch {
242    return
243  }
244  await update($, pane, prev => ({ ...EMPTY_NAV, ...prev, steps }))
245}
246
247/** A doc changed under .agent/: steps now, the rest of the pane at turn end. */
248const docsTouched = async ($: EngineInterface): Promise<void> => {
249  await update($, activity, a => ({ ...NO_ACTIVITY, ...a, docsTouched: true }))
250  const root = await projectRoot(ioOf($))
251  if (root !== null) await refreshSteps($, root)
252}
253
254/** TASK-86: `y` / `x` on the latest decision → the personal label file judge_label.py reads. */
255const labelLatest = async ($: EngineInterface, label: 'confirmed' | 'disputed'): Promise<void> => {
256  const io = ioOf($)
257  const latest = await read($, judge)
258  const root = await projectRoot(io)
259  if (latest === null || root === null) return
260  const path = `${personalDir(await io.env())}/${JUDGE_LABELS_FILE}`
261  const raw = await io.read(path).catch(() => null)
262  const project = root.split('/').filter(Boolean).pop() ?? root
263  try {
264    await io.write(path, withLabel(raw, labelEntry(latest, label, project)))
265  } catch {
266    $.ui.toast(`${PLUGIN}: could not write ${path}`)
267    return
268  }
269  const mark = (j: NavJudge | null) => (j !== null && j.at === latest.at ? { ...j, label } : j)
270  await update($, judge, mark)
271  await update($, judgeTrail, trail => (trail ?? []).map(j => mark(j) as NavJudge))
272  $.ui.toast(label === 'confirmed' ? `Labeled as ${latest.verdict}` : 'Marked disputed — label it later with judge_label.py')
273}
274
275/** The `judge` section of the shared runtime state (tallies behind the pane's `j`). */
276const judgeSection = async ($: EngineInterface): Promise<unknown> => {
277  const io = ioOf($)
278  const root = await projectRoot(io)
279  if (root === null) return null
280  return (await readJson(io, `${root}/${STATE_FILE}`))?.judge ?? null
281}
282
283// The Prometheus of `.agent/grafana/docker-compose.yml` (host port 9092).
284const PROMETHEUS_URL = 'http://localhost:9092'
285const TRIP_TIMEOUT_S = 1
286
287/**
288 * The trip panel's numbers from a Prometheus on this machine; null (panel hidden) when the stack
289 * is down, has no Claude Code metrics, the URL is not loopback, `dashboard.enabled` is false, or
290 * under Pilot. Runs on /nav and refresh. Read with curl, not $.http.fetch: a session with
291 * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC refuses every plugin fetch, loopback included, and
292 * nothing here leaves the host.
293 */
294const refreshTrip = async ($: EngineInterface): Promise<void> => {
295  const io = ioOf($)
296  const root = await projectRoot(io)
297  const cfg = root === null ? null : await loadConfig(io, root)
298  const base = String(getPath(cfg, 'dashboard.prometheus_url', PROMETHEUS_URL)).replace(/\/+$/, '')
299  if (root === null || cfg === null || getPath(cfg, 'dashboard.enabled', true) !== true
300    || !isLoopback(base) || await isPilotExecutor(io)) {
301    await update($, trip, () => null)
302    return
303  }
304  const get = async (path: string, query: string, extra = ''): Promise<string | null> => {
305    const url = `${base}/api/v1/${path}?query=${encodeURIComponent(query)}${extra}`
306    const out = await run(io, ['curl', '-sfg', '--noproxy', '*', '--max-time', String(TRIP_TIMEOUT_S), url],
307      root, (TRIP_TIMEOUT_S + 1) * 1000)
308    return out === '' ? null : out
309  }
310  const now = await $.clock.now()
311  const end = Math.floor(now / 1000)
312  const queries = Object.entries(tripQueries(secondsSinceMidnight(now)))
313  // One probe first, so a stopped stack costs one refused connection, not eleven.
314  const probe = await get('query', tripQueries(0)['week.tokens']!, `&time=${end}`)
315  const probed = probe === null ? null : parseVector(probe)
316  if (probed === null || Object.keys(probed).length === 0) {
317    await update($, trip, () => null)
318    return
319  }
320  const [range, ...vectors] = await Promise.all([
321    get('query_range', PER_MINUTE,
322      `&start=${end - PER_MINUTE_SPAN_SEC}&end=${end}&step=${PER_MINUTE_STEP_SEC}`),
323    ...queries.map(([, q]) => get('query', q, `&time=${end}`)),
324  ])
325  const results = Object.fromEntries(queries.map(([key], i) => {
326    const text = vectors[i]
327    return [key, text == null ? null : parseVector(text)]
328  }))
329  const built = buildTrip(results, range == null ? null : parseMatrix(range))
330  const source = `prometheus ${/:\d+$/.exec(base)?.[0] ?? base}`
331  await update($, trip, () => (built === null ? null : { ...built, source }))
332}
333
334/** Run the ops registered for a (Python-named) event; null outside a Navigator project. */
335const runFor = async (
336  $: EngineInterface, event: string, payload: Record<string, unknown>, extra: Partial<OpCtx> = {},
337) => {
338  const io = ioOf($)
339  const made = await makeCtx(io, event, payload)
340  if (made === null) return null
341  const ctx: OpCtx = { ...made, ...extra }
342  // runtime._surface_health: SessionStart leads with the last dispatch error, if unsurfaced.
343  const leading = event === 'SessionStart' ? await surfaceHealth(io, `${ctx.root}/.agent`) : null
344  const merged = await runEvent(ctx, EVENT_OPS[event] ?? [], leading)
345  if (merged.toast) $.ui.toast(merged.toast, { timeoutMs: 10000 })
346  return merged
347}
348
349/** The settings-hook payload shape the Python ops read, rebuilt from a tool.call event. */
350const toolPayload = (e: Record<string, unknown>): Record<string, unknown> => {
351  const { tool, tool_use_id: id, ...input } = e
352  return { tool_name: tool, tool_input: input, tool_use_id: id }
353}
354
355/** Append one context entry to a classic result (the chain rule: never drop theirs). */
356const withContext = <R extends { additionalContext?: string[] }>(r: R, text: string | null | undefined): R =>
357  text ? { ...r, additionalContext: [...(r.additionalContext ?? []), text] } : r
358
359// MultiEdit no longer exists as a tool on CC 2.1.287; Python's matcher still lists it (harmless).
360const MUTATING = ['Edit', 'Write', 'NotebookEdit'] as const
361
362const OFF_ROUTE_AFTER = 2 // consecutive substantive prompts away from the destination
363const LOW_FUEL_TURNS = 5
364const TASK_LABEL = 10 // task card: label column before the text
365const MEMORY_LINES = 3
366const JUDGE_TRAIL_LINES = 8
367const PANE_COLUMNS = 72 // asked for; the terminal decides (e.props.bodyColumns is the truth)
368const AREA_ROWS = 2 // braille trend under a card's numbers (grom's stat texture)
369
370/** Where the session is headed: a goal Claude stated in a brief, else the active task. */
371const navState = async ($: EngineInterface) => {
372  const p = await readPane($)
373  const s = await read($, status)
374  const chosen = await read($, destination)
375  const task = p.tasks[p.tasks.length - 1] ?? null
376  const dest: NavDestination | null = chosen
377    ?? (task ? { title: task.title || task.id, taskId: task.id, source: 'task' } : null)
378  const route: Waypoint[] = buildRoute(p.steps ?? [], s?.phase ?? null)
379  return { p, s, dest, route, here: currentWaypoint(route) }
380}
381
382const destinationWords = (dest: NavDestination | null, route: readonly Waypoint[]): Set<string> =>
383  dest === null ? new Set() : contentWords([dest.title, dest.taskId ?? '', ...route.map(w => w.label)].join(' '))
384
385/** Write a task stub for the detour and return to the route. */
386const parkDetour = async ($: EngineInterface): Promise<void> => {
387  const detour = await read($, offRoute)
388  const root = await projectRoot(ioOf($))
389  if (detour === null || root === null) return
390  const { dest } = await navState($)
391  const names = (await $.fs.list(`${root}/.agent/tasks`).catch(() => [])).map(f => f.name)
392  const id = `TASK-${nextTaskNumber(names)}`
393  const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
394  await $.fs.write(`${root}/.agent/tasks/${id}-${slugOf(detour.topic)}.md`,
395    parkedTaskDoc(id, detour.topic, detour.prompt, dest?.title ?? 'no destination', date))
396  await update($, offRoute, () => null)
397  $.ui.toast(`Parked as ${id}: ${detour.topic}`)
398}
399
400/** Make the detour the destination; the old one stays in the task list. */
401const switchToDetour = async ($: EngineInterface): Promise<void> => {
402  const detour = await read($, offRoute)
403  if (detour === null) return
404  const title = detour.prompt.split('\n')[0]?.trim().slice(0, 60) || detour.topic
405  await update($, destination, (): NavDestination => ({ title, taskId: null, source: 'brief' }))
406  await update($, offRoute, () => null)
407  await update($, waypointClock, () => null)
408}
409
410/** TASK-81: one read-only notice when a newer stable release exists; never updates. */
411const checkForUpdate = async ($: EngineInterface): Promise<void> => {
412  const io = ioOf($)
413  if (await isPilotExecutor(io)) return
414  const root = await projectRoot(io)
415  if (root === null) return
416  const { enabled, intervalHours, curlFallback } = updateSettings(await loadConfig(io, root))
417  if (!enabled) return
418  const now = await $.clock.now()
419  let latest = (await $.store.get('update_latest')) as string | null | undefined
420  if (dueForCheck(await $.store.get('update_checked_at'), now, intervalHours)) {
421    // A refused fetch (CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC refuses every plugin fetch)
422    // repeats the same GET through curl when the user opted in; a non-2xx answer does not.
423    const fetched = $.http.fetch(RELEASES_URL, { headers: { Accept: 'application/vnd.github+json' } })
424      .then(r => (r.ok ? r.text : null), () => (curlFallback ? releasesViaCurl(io, root) : null))
425    const text = await Promise.race([fetched, $.clock.sleep(4500).then(() => null)]).catch(() => null)
426    if (text !== null) {
427      latest = latestStable(text)
428      await $.store.set('update_latest', latest ?? null)
429      await $.store.set('update_checked_at', now)
430    }
431  }
432  const manifest = await readJson(io, `${$.plugin.root}/.claude-plugin/plugin.json`)
433  const installed = typeof manifest?.version === 'string' ? manifest.version : null
434  const notice = installed === null ? null : updateNotice(installed, latest ?? null)
435  if (notice !== null) $.ui.toast(notice, { timeoutMs: 15000 })
436}
437
438export const register: Register = on => {
439  on('session.start', async ($, e, next) => {
440    await announce(ioOf($))
441    await checkForUpdate($).catch(() => {})
442    await $.command.register({ name: 'nav', description: 'Open the Navigator pane' })
443    await refreshPane($)
444    return next(e)
445  })
446
447  // Runs before the Python SessionStart child (modules precede settings hooks), so the
448  // handoff env is in place for it. $.state resets on /clear, /resume and /branch, and
449  // session.start does not fire again, so the pane reloads here too.
450  on('classic.SessionStart', async ($, e, next) => {
451    // Announce before next(): modules run before settings hooks, so the Python
452    // SessionStart child already sees what the mod owns.
453    await announce(ioOf($))
454    if (['clear', 'resume', 'fork', 'compact'].includes(e.source)) {
455      await Promise.all([refreshPane($), refreshTrip($).catch(() => {})])
456    }
457    const r = await next(e)
458    const merged = await runFor($, 'SessionStart', e as unknown as Record<string, unknown>)
459    return withContext(r, merged?.context)
460  })
461
462  on('classic.PreCompact', async ($, e, next) => {
463    const r = await next(e)
464    await runFor($, 'PreCompact', e as unknown as Record<string, unknown>)
465    return r
466  })
467
468  on('classic.PostCompact', async ($, e, next) => {
469    const r = await next(e)
470    await runFor($, 'PostCompact', e as unknown as Record<string, unknown>)
471    return r
472  })
473
474  on('classic.SubagentStart', async ($, e, next) => {
475    const r = await next(e)
476    const merged = await runFor($, 'SubagentStart', e as unknown as Record<string, unknown>)
477    return withContext(r, merged?.context)
478  })
479
480  on('classic.ConfigChange', async ($, e, next) => {
481    const r = await next(e)
482    await runFor($, 'ConfigChange', e as unknown as Record<string, unknown>)
483    return r
484  })
485
486  on('classic.Setup', async ($, e, next) => {
487    const r = await next(e)
488    const merged = await runFor($, 'Setup', e as unknown as Record<string, unknown>)
489    return withContext(r, merged?.context)
490  })
491
492  on('command.run', { command: 'nav' }, async $ => {
493    await Promise.all([refreshPane($), refreshTrip($).catch(() => {})])
494    await $.ui.open({
495      id: PANE, title: 'Navigator', focus: true, closeOnEscape: true, columns: PANE_COLUMNS,
496    })
497    return {}
498  })
499
500  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
501    const pre = await runFor($, 'PreToolUse', toolPayload(e as unknown as Record<string, unknown>))
502    if (pre?.deny != null) return { deny: pre.deny }
503    const ran = await next(e)
504    const isDoc = e.file_path.includes('/.agent/')
505    if (ran.deny === undefined) {
506      await update($, reads, r => countRead({ ...NO_READS, ...r }, isDoc))
507    }
508    if (isDoc && ran.deny === undefined) {
509      const bytes = (ran.text ?? '').length
510      await update($, activity, a => {
511        const x = { ...NO_ACTIVITY, ...a }
512        return { ...x, docsBytes: x.docsBytes + bytes, docsReads: x.docsReads + 1 }
513      })
514    }
515    // v8: read_guard's warn reaches the model as context (v7 printed it to stderr only).
516    const warn = pre?.notes ?? null
517    return warn !== null && ran.deny === undefined ? { ...ran, context: [...(ran.context ?? []), warn] } : ran
518  })
519
520  on('tool.call', { tool: MUTATING }, async ($, e, next) => {
521    const ran = await next(e)
522    if (ran.deny !== undefined || ran.isError === true) return ran
523    const target = String((e as { file_path?: string; notebook_path?: string }).file_path
524      ?? (e as { notebook_path?: string }).notebook_path ?? '')
525    if (target.includes('/.agent/')) await docsTouched($)
526    const post = await runFor($, 'PostToolUse', toolPayload(e as unknown as Record<string, unknown>))
527    return post?.context ? { ...ran, context: [...(ran.context ?? []), post.context] } : ran
528  })
529
530  on('classic.TaskCreated', async ($, e, next) => {
531    const r = await next(e)
532    await runFor($, 'TaskCreated', e as unknown as Record<string, unknown>)
533    return r
534  })
535
536  on('classic.TaskCompleted', async ($, e, next) => {
537    const r = await next(e)
538    await runFor($, 'TaskCompleted', e as unknown as Record<string, unknown>)
539    return r
540  })
541
542  on('classic.PostToolUseFailure', async ($, e, next) => {
543    const r = await next(e)
544    const merged = await runFor($, 'PostToolUseFailure', e as unknown as Record<string, unknown>)
545    return withContext(r, merged?.context)
546  })
547
548  on('classic.Stop', async ($, e, next) => {
549    const r = await next(e)
550    // TASK-85: Claude Code's own read-only verdict on every Bash call of the turn.
551    const a = await readActivity($)
552    const merged = await runFor($, 'Stop', e as unknown as Record<string, unknown>,
553      { bashAllReadOnly: a.bashCalls > 0 && !a.bashMutating })
554    return merged?.block != null ? { ...r, block: merged.block } : r
555  })
556
557  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
558    const ran = await next(e)
559    if (ran.deny === undefined && ran.isError !== true && bashReadonly(e.command)) {
560      // TASK-87: `cat` / `sed -n` / `head` on a file is a read too; docs are `.agent/` paths.
561      for (const file of bashReadFiles(e.command)) {
562        await update($, reads, r => countRead({ ...NO_READS, ...r }, isDocPath(file)))
563      }
564    }
565    if (ran.deny === undefined) {
566      const readOnly = ran.isReadOnly === true
567      await update($, activity, a => ({
568        ...NO_ACTIVITY, ...a, bashCalls: (a?.bashCalls ?? 0) + 1,
569        bashMutating: (a?.bashMutating ?? false) || !readOnly,
570      }))
571      // TASK-89: a heredoc, `sed -i`, `git mv` or a commit can change the task list and the
572      // active doc as surely as Edit; the pane followed only Edit/Write before.
573      if (!readOnly && ran.isError !== true && !bashReadonly(e.command) && bashTouchesDocs(e.command)) {
574        await docsTouched($)
575      }
576    }
577    if (/\bgit\s+commit\b/.test(e.command) && ran.deny === undefined && ran.isError !== true) {
578      await update($, activity, a => ({ ...NO_ACTIVITY, ...a, committed: true }))
579    }
580    return ran
581  })
582
583  on('prompt.submit', async ($, e, next) => {
584    const ctx = await makeCtx(ioOf($), 'UserPromptSubmit', { prompt: e.text })
585    const merged = ctx === null ? null : await runEvent(ctx, EVENT_OPS.UserPromptSubmit ?? [])
586    if (merged?.drop != null) return { drop: merged.drop }
587    if (ctx !== null) {
588      const text = redactSecrets(Array.from(e.text).slice(0, Number(JUDGE_DEFAULTS.max_state_chars)).join(''))
589      const view = judgeView(ctx.judgment as Judgment | null | undefined, ctx.judgeAxes, merged?.context ?? null,
590        { text, at: await $.clock.now() })
591      await update($, judge, () => view)
592      if (view !== null) await update($, judgeTrail, trail => [view, ...(trail ?? [])].slice(0, JUDGE_TRAIL_MAX))
593    }
594    const memory = await read($, pinned)
595    if (memory !== null) await update($, pinned, () => null)
596    const extra = [
597      ...(merged?.context ? [merged.context] : []),
598      ...(memory === null ? [] : [`Navigator memory pinned by the user for this prompt: ${memory}`]),
599    ]
600    const entered = extra.length > 0
601      ? await next({ ...e, context: [...(e.context ?? []), ...extra] })
602      : await next(e)
603    if (entered.drop === undefined) {
604      const { dest, route } = await navState($)
605      if (isOffRoute(e.text, destinationWords(dest, route))) {
606        await update($, offRoute, prev => ({
607          topic: prev?.topic ?? detourTopic(e.text),
608          prompt: prev?.prompt ?? e.text,
609          count: (prev?.count ?? 0) + 1,
610        }))
611      } else if (contentWords(e.text).size >= 4) {
612        await update($, offRoute, () => null) // back on the destination's vocabulary
613      }
614    }
615    return entered
616  })
617
618  on('turn.complete', async ($, e, next) => {
619    if (e.agentId !== undefined) {
620      const used = e.usage
621      const tokens = used === undefined ? 0
622        : used.input_tokens + used.cache_read_input_tokens + used.cache_creation_input_tokens
623          + used.output_tokens
624      await update($, activity, a => {
625        const x = { ...NO_ACTIVITY, ...a }
626        // TASK-89: an agent's edits do not pass through this module's tool.call; assume it
627        // may have written under .agent/ and reload the pane when the parent turn ends.
628        return { ...x, agentRuns: x.agentRuns + 1, agentTokens: x.agentTokens + tokens, docsTouched: true }
629      })
630      return next(e)
631    }
632    const u = await $.session.usage()
633    const fresh = statusOf(e.answer, u.context.percent === undefined ? null : u.context.percent)
634    await update($, status, prev => ({
635      phase: fresh.phase ?? prev?.phase ?? null,
636      next: fresh.next ?? prev?.next ?? null,
637      ctxPercent: fresh.ctxPercent,
638    }))
639    await update($, usage, () => ({
640      rates: u.rateLimits.map(r => ({
641        kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null,
642      })),
643      usd: u.cost?.usd ?? null,
644    }))
645    const a = await readActivity($)
646    const tree = (await readPane($)).docsTreeBytes
647    const h = await readHistory($)
648    await update($, activity, x => {
649      const y = { ...NO_ACTIVITY, ...x }
650      return { ...y, committed: false, lastTurnCommitted: y.committed, docsTouched: false, bashCalls: 0, bashMutating: false }
651    })
652    await update($, reads, r => endTurnReads({ ...NO_READS, ...r }))
653    // The pane follows the session without `r`: Prometheus every turn (local, ~12 curls, one
654    // refused probe when the stack is down); the task list, marker and memories when a turn
655    // wrote under .agent/ (three subprocess spawns, so not every turn).
656    const rootForRejects = await projectRoot(ioOf($))
657    await Promise.all([
658      refreshTrip($).catch(() => {}),
659      a.docsTouched ? refreshPane($) : Promise.resolve(),
660      rootForRejects === null ? Promise.resolve() : refreshRejects($, rootForRejects).catch(() => {}),
661    ])
662    await update($, history, () => ({
663      ctx: [...h.ctx, fresh.ctxPercent ?? 0].slice(-64),
664      saved: [...h.saved, tokensOf(Math.max(0, tree - a.docsBytes))].slice(-64),
665    }))
666    const goal = captureGoal(e.answer)
667    if (goal !== null) await update($, destination, (): NavDestination => ({ title: goal, taskId: null, source: 'brief' }))
668    const { here } = await navState($)
669    const label = here?.label ?? null
670    const before = await read($, waypointClock)
671    const left = before !== null && before.label !== label ? before.turns + 1 : null
672    await update($, pace, prev => recordPace({ ...NO_PACE, ...prev }, e.durationMs, left))
673    await update($, waypointClock, prev => (label === null ? null
674      : prev?.label === label ? { label, turns: prev.turns + 1 } : { label, turns: 0 }))
675    return next(e)
676  })
677
678  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
679    if (e.props.hasSurvey) return next(e)
680    const { s, dest, route, here } = await navState($)
681    const detour = await read($, offRoute)
682    const left = turnsTo((await readHistory($)).ctx, 70)
683    const percent = s?.ctxPercent ?? null
684    const at = here === null ? 0 : route.indexOf(here) + 1
685    const text = bandText({
686      label: dest === null ? null : dest.taskId ?? dest.title,
687      waypoint: here?.label ?? null,
688      position: at,
689      total: route.length,
690      offRoute: (detour?.count ?? 0) >= OFF_ROUTE_AFTER ? (detour?.topic ?? 'a detour') : null,
691      lowFuel: (percent !== null && percent >= 70) || (left !== null && left <= LOW_FUEL_TURNS),
692      ctxPercent: percent,
693    }, e.props.bodyColumns)
694    if (!text) return next(e)
695    const { Box, Text } = $.ui.resolve(e)
696    return (
697      <Box>
698        <Text color={PALETTE.dim} wrap="truncate-end">{text}</Text>
699      </Box>
700    )
701  })
702
703  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
704    const { Box, Text, Button } = $.ui.resolve(e)
705    const { p, s, dest, route, here } = await navState($)
706    const u = await read($, usage)
707    const a = await readActivity($)
708    const hist = await readHistory($)
709    const detour = await read($, offRoute)
710    const clock = await read($, waypointClock)
711    const t = await read($, trip)
712    const r = { ...NO_READS, ...((await read($, reads)) ?? {}) }
713    const j = await read($, judge)
714    const judgeOpen = await read($, showJudge)
715    const trail = judgeOpen ? ((await read($, judgeTrail)) ?? []) : []
716    const details = await read($, showDetails)
717    const rj = await read($, rejects)
718    const rejectsOpen = await read($, showRejects)
719    const pc = { ...NO_PACE, ...((await read($, pace)) ?? {}) }
720    const chosen = await read($, pinned)
721    const percent = s?.ctxPercent ?? null
722    const pct = percent === null ? '--%' : `${Math.round(percent)}%`
723    const ctxColor = percentColor(percent)
724    const left = turnsTo(hist.ctx, 70)
725    const lowFuel = (percent !== null && percent >= 70) || (left !== null && left <= LOW_FUEL_TURNS)
726    const isOff = (detour?.count ?? 0) >= OFF_ROUTE_AFTER
727    const at = here === null ? 0 : route.indexOf(here) + 1
728    const then = here === null ? null : route.slice(at).find(w => w.state === 'todo') ?? null
729    const legsLeft = route.filter(w => w.state !== 'done').length
730    const legPrompt = here === null ? null
731      : `Do the next leg${dest?.taskId ? ` of ${dest.taskId}` : ''}: ${here.label}`
732    const window = u?.rates[0]
733    const cacheHit = t?.today.cacheHit ?? t?.week.cacheHit ?? null
734    const tally = judgeOpen ? judgeTally(await judgeSection($)) : []
735    const panel = { borderStyle: 'round', borderColor: PALETTE.border, paddingX: 1 } as const
736    // A trend is a braille area in a subdued gradient of the card's color; flat ones go dim.
737    const trend = (values: readonly number[], width: number, color: string, tail = '') =>
738      brailleArea(values, width, AREA_ROWS).map((row, i, rows) => (
739        <Text wrap="truncate-end">
740          <Text color={isFlat(values) ? PALETTE.dim : areaColors(color, AREA_ROWS)[i]}>{row}</Text>
741          {i === rows.length - 1 && tail ? <Text color={PALETTE.dim}>{tail}</Text> : null}
742        </Text>
743      ))
744    const cols = typeof e.props.bodyColumns === 'number' && e.props.bodyColumns > 0 ? e.props.bodyColumns : PANE_COLUMNS
745    const layout = paneLayout(cols, details)
746    const { narrow, ctxInner, sessionInner, gaugeWidth } = layout
747    const cardWidth = (wide: string, withDetails: string) => (narrow ? '100%' : details ? withDetails : wide)
748    const title = (text: string, color: string = PALETTE.accent) => (
749      <Box marginBottom={1}><Text color={color}>{text}</Text></Box>
750    )
751    const dimLabel = (text: string) => <Text color={PALETTE.dim}>{text.padEnd(TASK_LABEL)}</Text>
752
753    return (
754      <Box flexDirection="column">
755        <Box flexDirection={narrow ? 'column' : 'row'}>
756          <Box {...panel} flexDirection="column" width={cardWidth('40%', '31%')}>
757            {title('context')}
758            <Text wrap="truncate-end">
759              <Text color={ctxColor} bold>{pct}</Text> <Text color={ctxColor}>{gauge(percent, gaugeWidth)}</Text>
760            </Text>
761            <Text color={lowFuel ? PALETTE.warning : PALETTE.dim} wrap="truncate-end">
762              {lowFuel ? 'compact due' : a.lastTurnCommitted ? 'good moment to compact' : 'compact safe'}
763            </Text>
764            {trend(hist.ctx, ctxInner, ctxColor)}
765          </Box>
766          <Box {...panel} flexDirection="column" width={cardWidth('60%', '43%')}>
767            {title(details && t !== null ? `session · ${t.source}` : 'session')}
768            {t === null ? (
769              <Box flexDirection="column">
770                <Text wrap="truncate-end">
771                  <Text color={PALETTE.success} bold>{u?.usd == null ? '--' : `$${u.usd.toFixed(2)}`}</Text>
772                  <Text color={PALETTE.dim}>{window ? `  ${rateKind(window.kind)} ${Math.round(window.percentUsed)}%` : ''}</Text>
773                </Text>
774                <Text color={PALETTE.label} wrap="truncate-end">{s?.phase ? `phase ${s.phase}` : 'phase —'}</Text>
775                <Text color={PALETTE.dim} wrap="truncate-end">{p.graph ? `graph ${compact(p.graph.nodes)} nodes` : 'graph —'}</Text>
776              </Box>
777            ) : (
778              <Box flexDirection="column">
779                <Text wrap="truncate-end">
780                  <Text color={PALETTE.success} bold>${t.today.usd.toFixed(2)}</Text>
781                  <Text color={PALETTE.dim}>  {compact(t.today.tokens)}{cacheHit === null ? '' : ` · ${Math.round(cacheHit * 100)}% cache`}</Text>
782                </Text>
783                <Text color={PALETTE.dim} wrap="truncate-end">7d ${t.week.usd.toFixed(2)} · {t.week.commits} commits</Text>
784                {trend(t.perMinute, sessionInner - 12, PALETTE.accent, '  tokens/min')}
785              </Box>
786            )}
787          </Box>
788          {details ? (
789            <Box {...panel} flexDirection="column" width={narrow ? '100%' : '26%'}>
790              {title('reads')}
791              <Text wrap="truncate-end">
792                <Text color={PALETTE.accent} bold>{r.total}</Text>
793                <Text color={PALETTE.dim}>  {r.docs} docs</Text>
794              </Text>
795              <Text color={fanOutText(r) === 'fan-out ok' ? PALETTE.dim : PALETTE.warning}>{fanOutText(r)}</Text>
796              <Text color={(rj?.today ?? 0) > 0 ? PALETTE.warning : PALETTE.dim} wrap="truncate-end">{rejectsLine(rj)}</Text>
797              {rejectsLast(rj) === null ? null : <Text color={PALETTE.dim} wrap="truncate-end">{rejectsLast(rj)}</Text>}
798            </Box>
799          ) : null}
800        </Box>
801
802        {rejectsOpen ? (
803          <Box {...panel} flexDirection="column">
804            {title('rejects')}
805            {rj === null || rj.tail.length === 0
806              ? <Text color={PALETTE.dim}>no refusals logged ({REJECTS_PATH})</Text>
807              : rj.tail.map((line, i) => <Text key={`rj-${i}`} color={PALETTE.dim} wrap="truncate-end">{line}</Text>)}
808          </Box>
809        ) : null}
810
811        {j === null ? null : (
812          <Box {...panel} flexDirection="column">
813            <Box flexDirection="row" justifyContent="space-between">
814              <Text wrap={narrow ? 'wrap' : 'truncate-end'}>
815                <Text color={PALETTE.accent}>judge  </Text>
816                <Text color={PALETTE.label} bold>{j.verdict}</Text>
817                <Text color={PALETTE.dim}>  → {j.effect}</Text>
818                {j.override === null ? null : <Text color={PALETTE.warning}>   {j.override}</Text>}
819              </Text>
820              {narrow ? null : <Box marginLeft={2} flexShrink={0}><Text color={PALETTE.dim}>{j.model}</Text></Box>}
821            </Box>
822            {narrow ? <Text color={PALETTE.dim}>{j.model}</Text> : null}
823            {tally.map(line => <Text color={PALETTE.dim} wrap="truncate-end">{line}</Text>)}
824            {trail.length > 0 ? <Text> </Text> : null}
825            {trail.slice(0, JUDGE_TRAIL_LINES).map((d, i) => (
826              <Text color={i === 0 ? PALETTE.label : PALETTE.dim} wrap="truncate-end">{trailLine(d, cols - 4)}</Text>
827            ))}
828            {judgeOpen && j.label === undefined ? (
829              <Box flexDirection="row" columnGap={3} marginTop={1}>
830                <Button key="confirm" label="verdict right" hotkey="y" plain onPress={() => labelLatest($, 'confirmed')} />
831                <Button key="dispute" label="wrong" hotkey="x" plain onPress={() => labelLatest($, 'disputed')} />
832              </Box>
833            ) : null}
834          </Box>
835        )}
836
837        <Box {...panel} flexDirection="column">
838          {title('next')}
839          {dest === null ? (
840            <Text color={PALETTE.dim} wrap="wrap">no destination · say what you're building, or mark a task in progress</Text>
841          ) : (
842            <Text wrap={narrow ? 'wrap' : 'truncate-end'}>
843              <Text color={PALETTE.accent} bold>{(dest.taskId ?? 'brief').padEnd(TASK_LABEL)}</Text>
844              <Text color={PALETTE.label}>{dest.title}</Text>
845            </Text>
846          )}
847          {here !== null && legPrompt !== null ? (
848            <Box flexDirection="row" justifyContent="space-between">
849              <Box flexDirection="row">
850                <Button key="leg" label={`● ${at}/${route.length}  ${here.label}`} hotkey="n"
851                  onPress={() => $.prompt.submit({ text: legPrompt, asUser: true })} />
852              </Box>
853              <Box marginLeft={2} flexShrink={0}>
854                <Text color={PALETTE.dim}>
855                  {[clock && clock.turns > 0 ? `${clock.turns} ${clock.turns === 1 ? 'turn' : 'turns'} here` : null,
856                    then === null ? etaText(legsLeft, pc) : null].filter(Boolean).join(' · ')}
857                </Text>
858              </Box>
859            </Box>
860          ) : arrived(route) ? (
861            <Text color={PALETTE.success}>arrived · pick the next destination</Text>
862          ) : s?.next ? (
863            <Text wrap={narrow ? 'wrap' : 'truncate-end'}>{dimLabel('→ next')}<Text color={PALETTE.label}>{s.next}</Text></Text>
864          ) : null}
865          {then === null ? null : (
866            <Box flexDirection="row" justifyContent="space-between">
867              <Text wrap="truncate-end">{dimLabel('→ then')}<Text color={PALETTE.dim}>{then.label}</Text></Text>
868              <Box marginLeft={2} flexShrink={0}>
869                <Text color={PALETTE.dim}>{etaText(legsLeft, pc)}</Text>
870              </Box>
871            </Box>
872          )}
873          {p.marker === null ? null : (
874            <Text wrap={narrow ? 'wrap' : 'truncate-end'}>{dimLabel('marker')}<Text color={PALETTE.dim}>{p.marker}</Text></Text>
875          )}
876        </Box>
877
878        {isOff && detour !== null ? (
879          <Box {...panel} borderColor={PALETTE.warning} flexDirection="column">
880            {title('⚠ off route', PALETTE.warning)}
881            <Text wrap="truncate-end" color={PALETTE.label}>last {detour.count} prompts are about "{detour.topic}"</Text>
882            <Text wrap="truncate-end" color={PALETTE.dim}>
883              destination is "{dest?.title ?? 'not set'}"{here ? ` (${here.label})` : ''}
884            </Text>
885            <Text> </Text>
886            <Box flexDirection="row" columnGap={3}>
887              <Button key="park" label="park as new task" hotkey="p" plain onPress={() => parkDetour($)} />
888              <Button key="back" label="back to route" hotkey="b" plain
889                onPress={() => update($, offRoute, () => null)} />
890              <Button key="switch" label="switch" hotkey="s" plain onPress={() => switchToDetour($)} />
891            </Box>
892          </Box>
893        ) : null}
894
895        <Box {...panel} flexDirection="column">
896          {title('memories')}
897          {p.memories.length === 0 && <Text color={PALETTE.dim}>none for the open tasks</Text>}
898          {p.memories.slice(0, MEMORY_LINES).map((m, i) => (
899            <Box flexDirection="row">
900              <Button key={`mem-${i}`} label={chosen === m.text ? '●' : '▸'} plain
901                onPress={() => update($, pinned, () => (chosen === m.text ? null : m.text))} />
902              <Text wrap="truncate-end">
903                <Text> </Text>
904                <Text color={PALETTE.accent} bold>{m.kind.toLowerCase()}</Text>
905                <Text color={PALETTE.dim}>{m.percent === null ? '' : ` ${m.percent}%`}</Text>
906                <Text color={chosen !== null && chosen !== m.text ? PALETTE.dim : PALETTE.label}>  {m.text}</Text>
907              </Text>
908            </Box>
909          ))}
910          <Text color={PALETTE.dim}>
911            {chosen === null ? '▸ pins a memory into your next prompt' : '● pinned for your next prompt'}
912          </Text>
913        </Box>
914
915        {details ? (
916          <Box {...panel} flexDirection="column">
917            {title('tasks')}
918            {p.tasks.length === 0 && <Text color={PALETTE.dim}>none marked in progress</Text>}
919            {[...p.tasks].reverse().slice(0, 5).map(task => (
920              <Text wrap="truncate-end">
921                <Text color={task.id === dest?.taskId ? PALETTE.success : PALETTE.dim}>{task.id === dest?.taskId ? '● ' : '○ '}</Text>
922                <Text color={task.id === dest?.taskId ? PALETTE.label : PALETTE.dim}>{task.id}</Text>
923                <Text color={PALETTE.dim}>{task.title ? `  ${task.title}` : ''}</Text>
924              </Text>
925            ))}
926          </Box>
927        ) : null}
928
929        <Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingX={1}>
930          <Button key="marker" label="marker" hotkey="m" plain
931            onPress={() => $.prompt.submit({ text: 'Create context marker checkpoint', asUser: true })} />
932          <Text color={PALETTE.dim}>·</Text>
933          <Button key="compact" label="compact" hotkey="c" plain onPress={() => $.session.compact()} />
934          <Text color={PALETTE.dim}>·</Text>
935          <Button key="refresh" label="refresh" hotkey="r" plain onPress={() => Promise.all([refreshPane($), refreshTrip($).catch(() => {})])} />
936          <Text color={PALETTE.dim}>·</Text>
937          <Button key="details" label="details" hotkey="d" plain onPress={() => update($, showDetails, v => !v)} />
938          <Text color={PALETTE.dim}>·</Text>
939          <Button key="rejects" label="rejects" hotkey="l" plain onPress={() => update($, showRejects, v => !v)} />
940          {j === null ? null : <Text color={PALETTE.dim}>·</Text>}
941          {j === null ? null : (
942            <Button key="judge" label="judge" hotkey="j" plain onPress={() => update($, showJudge, v => !v)} />
943          )}
944        </Box>
945      </Box>
946    )
947  })
948}
949
hooks/mod/lib/reply_modes.ts 168 lines
1// Reply modes (ADHD TASK-82, STE TASK-93), mirrored from hooks/nav_hook_lib/reply_modes.py
2// and hooks/ops/prompt_modes.py. Pure: no `$` here. The op does the I/O and feeds these
3// functions. Every mode is one row in MODES; adding a mode is adding a row here and the
4// mirrored row in the Python table. Table order is injection order: reply-shape rules
5// (ADHD) before sentence rules (STE), so the later block applies inside the earlier one.
6
7export const OP_CONFIG_KEY = 'reply_modes'
8export const MAX_PHRASE_CHARS = 32
9
10export type Mode = {
11  key: string         // short word in toggle phrases: "adhd mode on"
12  label: string       // human label in answers: "ADHD mode: on"
13  configKey: string   // repo block: <configKey>.enabled / <configKey>.on
14  stateName: string   // personal file: ~/.config/navigator/<stateName>.json
15  ruleBlock: string
16  extraOn: readonly string[]
17  extraOff: readonly string[]
18}
19
20export const ADHD_RULE_BLOCK = `ADHD MODE: on (personal setting; "adhd mode off" ends it)
21The reader has ADHD. The reply shape that works for them:
22- First line: the ONE next action. Time-critical items first, the deadline in bold.
23- Bullets over prose. Lists hold at most 5 items; longer ones split into now / later.
24- Multi-step work: numbered, one bounded action per step, "step k of n" restated each turn.
25- Several things pending: the single most important one is named, not a flat list.
26- Errors: cause and fix in a flat tone. Wins stated plainly.
27- No preamble, no recap, no pleasantries. The reply ends with one action under two minutes.
28Kept in full regardless: error output, test results, anything the user asked to have
29explained, and every warning before a destructive action.
30Applies to replies only, not to files, reports or commit messages.`
31
32export const STE_RULE_BLOCK = `STE MODE: on (personal setting; "ste mode off" ends it)
33The reader wants ASD-STE100 Simplified Technical English. The sentence style that works:
34- One idea per sentence. Instructions have 20 words or fewer, descriptions 25 or fewer.
35- Instructions use the imperative. Descriptions use the active voice and the present tense.
36- One instruction per sentence. A paragraph has one topic and six sentences or fewer.
37- No gerunds, no noun clusters over three words, no synonyms for one meaning.
38- Full sentences with articles, not labels. Technical names stay as written in the code.
39Error output, test results and quoted text stay verbatim. Applies to replies only, inside
40any reply-shape rules above.`
41
42export const MODES: readonly Mode[] = [
43  { key: 'adhd', label: 'ADHD', configKey: 'adhd_mode', stateName: 'adhd-mode',
44    ruleBlock: ADHD_RULE_BLOCK, extraOn: [], extraOff: [] },
45  { key: 'ste', label: 'STE', configKey: 'ste_mode', stateName: 'ste-mode',
46    ruleBlock: STE_RULE_BLOCK, extraOn: ['use ste'], extraOff: ['stop ste'] },
47]
48
49export const byKey = (key: string): Mode | undefined => MODES.find(m => m.key === key)
50export const byConfigKey = (configKey: string): Mode | undefined =>
51  MODES.find(m => m.configKey === configKey)
52
53// Exact toggle phrases (after normalize). Deliberately no fuzzy matching: a prompt that
54// merely mentions a mode must never flip its switch.
55export const onPhrases = (m: Mode): Set<string> => new Set([
56  `${m.key} mode on`, `${m.key} mode: on`, `${m.key} on`, `enable ${m.key} mode`,
57  `start ${m.key} mode`, `turn on ${m.key} mode`, `${m.key} mode enable`, ...m.extraOn,
58])
59export const offPhrases = (m: Mode): Set<string> => new Set([
60  `${m.key} mode off`, `${m.key} mode: off`, `${m.key} off`, `disable ${m.key} mode`,
61  `stop ${m.key} mode`, `turn off ${m.key} mode`, `${m.key} mode disable`, ...m.extraOff,
62])
63export const statusPhrases = (m: Mode): Set<string> => new Set([
64  `${m.key} mode`, `${m.key} mode?`, `${m.key} mode status`, `${m.key} status`,
65])
66
67export type Toggle = 'on' | 'off' | 'status'
68export type Source = 'repo' | 'personal' | 'default'
69export type Resolved = { on: boolean; source: Source }
70
71export type EnvVars = {
72  NAVIGATOR_CONFIG_HOME?: string
73  XDG_CONFIG_HOME?: string
74  HOME?: string
75}
76
77const normalize = (text: string): string =>
78  text.trim().toLowerCase().replace(/\s+/g, ' ').replace(/[ .!]+$/, '').trim()
79
80/** { mode, kind } for an exact toggle phrase of any mode, else null. */
81export const classify = (prompt: string): { mode: Mode; kind: Toggle } | null => {
82  if (!prompt || prompt.length > MAX_PHRASE_CHARS) return null
83  const phrase = normalize(prompt)
84  for (const mode of MODES) {
85    if (onPhrases(mode).has(phrase)) return { mode, kind: 'on' }
86    if (offPhrases(mode).has(phrase)) return { mode, kind: 'off' }
87    if (statusPhrases(mode).has(phrase)) return { mode, kind: 'status' }
88  }
89  return null
90}
91
92/** The personal Navigator config dir; same precedence as nav_hook_lib.personal.config_home(). */
93export const personalDir = (env: EnvVars): string =>
94  env.NAVIGATOR_CONFIG_HOME
95    ? env.NAVIGATOR_CONFIG_HOME
96    : `${env.XDG_CONFIG_HOME ?? `${env.HOME ?? '~'}/.config`}/navigator`
97
98export const personalPath = (env: EnvVars, mode: Mode): string =>
99  `${personalDir(env)}/${mode.stateName}.json`
100
101/** `<configKey>.enabled` — the mode's machinery is available (default true). */
102export const modeEnabled = (enabled: unknown): boolean => enabled === undefined || enabled === null
103  ? true
104  : Boolean(enabled)
105
106/** Repo pin (true/false) wins; a personal bool next; else off by default. */
107export const resolve = (pinned: unknown, personal: unknown): Resolved => {
108  if (typeof pinned === 'boolean') return { on: pinned, source: 'repo' }
109  if (typeof personal === 'boolean') return { on: personal, source: 'personal' }
110  return { on: false, source: 'default' }
111}
112
113/** One human line for status answers; '' when off by default. */
114export const statusLine = (mode: Mode, r: Resolved, path: string): string => {
115  if (r.source === 'default') return ''
116  const where = r.source === 'repo'
117    ? `pinned by repo config ${mode.configKey}.on`
118    : `personal switch, ${path}`
119  return `${mode.label} mode: ${r.on ? 'on' : 'off'} (${where}).`
120}
121
122/** Rule blocks of every enabled mode that resolves on, joined in table order; null when none. */
123export const injection = (blocks: readonly string[]): string | null =>
124  blocks.length > 0 ? blocks.join('\n\n') : null
125
126export type ToggleOutcome = {
127  mode: Mode
128  kind: Toggle
129  enabled: boolean
130  pinned: unknown
131  resolved: Resolved
132  path: string
133  wrote: boolean
134}
135
136/** The reason text a dropped toggle prompt shows; mirrors prompt_modes._toggle_reason. */
137export const toggleReason = (o: ToggleOutcome): string => {
138  const { label, key: word, configKey } = o.mode
139  if (!o.enabled) {
140    return `${label} mode: disabled in this repo (${configKey}.enabled is false ` +
141      'in .agent/.nav-config.json or the .local override). Nothing changed.'
142  }
143  if (o.kind === 'status') {
144    const line = statusLine(o.mode, o.resolved, o.path) || `${label} mode: off (never switched on).`
145    return `${line} Say "${word} mode on" or "${word} mode off".`
146  }
147  const wanted = o.kind === 'on'
148  if (!o.wrote) {
149    return `${label} mode: could not write the personal switch (${o.path}). Nothing changed.`
150  }
151  const state = wanted ? 'on' : 'off'
152  if (typeof o.pinned === 'boolean') {
153    return `${label} mode: personal switch set to ${state}, but this repo pins it ` +
154      `${o.pinned ? 'on' : 'off'} via ${configKey}.on in .agent/.nav-config.json ` +
155      '(or the .local override). Remove the pin for the switch to apply here.'
156  }
157  const tail = wanted
158    ? `Applies from your next prompt, in every repo. Say "${word} mode off" to stop.`
159    : 'The rule block is no longer injected anywhere.'
160  return `${label} mode: ${state} (personal, ${o.path}). ${tail}`
161}
162
163/** The personal file's body, same keys as reply_modes.set_personal (seconds precision, UTC). */
164export const personalBody = (on: boolean, nowMs: number): string => {
165  const stamp = new Date(Math.floor(nowMs / 1000) * 1000).toISOString().replace('.000Z', '+00:00')
166  return `${JSON.stringify({ on, updated: stamp }, null, 2)}\n`
167}
168
hooks/mod/lib/judge.ts 247 lines
1// Port of nav_hook_lib/judge.py (TASK-79/80, ported TASK-84): the TypeSafe typed judge.
2// One request per prompt answers eight questions; decisive axes override the heuristics.
3// Differences from Python, by platform: the key env var is fixed to TYPESAFE_API_KEY (the
4// mod can read only literal env names), and the timeout is a race against $.clock.sleep
5// ($.http.fetch has no timeout option).
6import {
7  AMBIGUITY_LEVELS, AMBIGUITY_WEIGHTS, COMPLEXITY_LEVELS, DIMENSIONS, JUDGE_DEFAULTS, QUESTIONS,
8  REDACTED,
9} from './gen/judge-data.gen'
10import type { Judgment } from './scoring'
11import type { Io, Json } from './types'
12
13export type JudgeSettings = typeof JUDGE_DEFAULTS & Json
14type Thresholds = { min_confidence: number; noul_low: number; noul_high: number }
15
16const W = '[\\p{L}\\p{N}_]'
17const B = `(?:(?<=${W})(?!${W})|(?<!${W})(?=${W}))`
18const SECRET_PATTERNS: RegExp[] = [
19  new RegExp(`${B}(?:api[_-]?key|token|secret|password|passwd|bearer)\\s*[:=]\\s*['"]?\\S+`, 'giu'),
20  new RegExp(`${B}apikey_[A-Za-z0-9_]{16,}`, 'gu'),
21  new RegExp(`${B}sk-[A-Za-z0-9_\\-]{16,}`, 'gu'),
22  new RegExp(`${B}(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}`, 'gu'),
23  new RegExp(`${B}AKIA[0-9A-Z]{16}${B}`, 'gu'),
24  new RegExp(`${B}xox[abprs]-[A-Za-z0-9\\-]{10,}`, 'gu'),
25  new RegExp(`${B}eyJ[A-Za-z0-9_\\-]{8,}\\.[A-Za-z0-9_\\-]{8,}\\.[A-Za-z0-9_\\-]{8,}`, 'gu'),
26  new RegExp(`${B}[0-9a-f]{40,}${B}`, 'gu'),
27]
28
29export const redactSecrets = (text: string): string =>
30  SECRET_PATTERNS.reduce((t, p) => t.replace(p, REDACTED), text)
31
32export const settings = (cfg: Json | null): JudgeSettings => {
33  const block = cfg?.judge
34  return { ...JUDGE_DEFAULTS, ...(block && typeof block === 'object' ? (block as Json) : {}) } as JudgeSettings
35}
36
37const thresholdsOf = (s: JudgeSettings): Thresholds => ({
38  min_confidence: Number(s.min_confidence ?? JUDGE_DEFAULTS.min_confidence),
39  noul_low: Number(s.noul_low ?? JUDGE_DEFAULTS.noul_low),
40  noul_high: Number(s.noul_high ?? JUDGE_DEFAULTS.noul_high),
41})
42
43/** Python slicing on code points: `text[:cap]`. */
44const head = (text: string, cap: number): string => Array.from(text).slice(0, cap).join('')
45
46export const buildRequest = (prompt: string, s: JudgeSettings): Json => ({
47  state: head(redactSecrets(prompt ?? ''), Number(s.max_state_chars || JUDGE_DEFAULTS.max_state_chars)),
48  model: String(s.model || JUDGE_DEFAULTS.model),
49  questions: QUESTIONS,
50})
51
52export const decide = (p: unknown, t: Thresholds): boolean | null => {
53  const v = typeof p === 'number' ? p : Number.parseFloat(String(p))
54  if (Number.isNaN(v)) return null
55  if (v >= t.noul_high) return true
56  if (v <= t.noul_low) return false
57  return null
58}
59
60/** Python round() on a float: banker's rounding at .5. */
61const pyRound = (x: number): number => {
62  const f = Math.floor(x)
63  const d = x - f
64  if (d === 0.5) return f % 2 === 0 ? f : f + 1
65  return Math.round(x)
66}
67
68const levelName = (levels: readonly string[], normalized: number): string => {
69  const index = pyRound(Math.max(0, Math.min(1, normalized)) * (levels.length - 1))
70  return (levels[index] ?? '').split(':', 1)[0] ?? ''
71}
72
73type Answer = { noul?: number; score?: number; confidence?: number; probabilities?: Record<string, number> }
74
75const scoreOf = (
76  answers: Record<string, Answer>, name: string, levels: readonly string[],
77  weights?: readonly number[],
78): [number, number] => {
79  const entry = answers[name]
80  if (entry === undefined) throw new Error(`missing answer ${name}`)
81  const top = levels.length - 1
82  const probs = entry.probabilities
83  let value: number
84  if (weights !== undefined && probs && Object.keys(probs).length > 0) {
85    value = levels.reduce((sum, _, i) => sum + Number(probs[String(i)] ?? 0) * (weights[i] ?? 0), 0)
86  } else {
87    if (entry.score === undefined) throw new Error(`missing score ${name}`)
88    value = Number(entry.score) / top
89  }
90  return [Math.max(0, Math.min(1, value)), Number(entry.confidence ?? 0)]
91}
92
93const noul = (answers: Record<string, Answer>, name: string): number => {
94  const v = answers[name]?.noul
95  if (v === undefined) throw new Error(`missing noul ${name}`)
96  return Number(v)
97}
98
99/** Map the API document onto a Judgment. Throws on a malformed shape (caller fails open). */
100export const parseResponse = (doc: Json, t: Thresholds, latencyMs = 0): Judgment => {
101  const answers = doc.answers as Record<string, Answer>
102  const [complexity, complexityConf] = scoreOf(answers, 'complexity', COMPLEXITY_LEVELS)
103  const [ambiguity, ambiguityConf] = scoreOf(answers, 'ambiguity', AMBIGUITY_LEVELS, AMBIGUITY_WEIGHTS)
104  const dims: Record<string, number> = {}
105  for (const d of DIMENSIONS) dims[d] = noul(answers, `${d}_defined`)
106  const isTask = noul(answers, 'is_task')
107  const wantsLoop = noul(answers, 'wants_loop')
108  return {
109    model: String(doc.model ?? ''),
110    latencyMs,
111    taskVerdict: () => decide(isTask, t),
112    loopVerdict: () => decide(wantsLoop, t),
113    complexityIfConfident: () => (complexityConf >= t.min_confidence ? complexity : null),
114    ambiguityIfConfident: () => (ambiguityConf >= t.min_confidence ? ambiguity : null),
115    dimensionVerdict: name => (name in dims ? decide(dims[name], t) : null),
116    complexityLevel: () => levelName(COMPLEXITY_LEVELS, complexity),
117  }
118}
119
120const expandHome = (path: string, home: string | undefined): string =>
121  path.startsWith('~/') && home ? `${home}${path.slice(1)}` : path
122
123/** Env var first, then the key file; null when neither yields a key. Never logged. */
124export const apiKey = async (io: Io, s: JudgeSettings): Promise<string | null> => {
125  const env = await io.env()
126  const fromEnv = (env.TYPESAFE_API_KEY ?? '').trim()
127  if (fromEnv) return fromEnv
128  try {
129    const path = expandHome(String(s.api_key_file || JUDGE_DEFAULTS.api_key_file), env.HOME)
130    const first = (await io.read(path)).trim().split('\n')[0]?.trim() ?? ''
131    return first || null
132  } catch {
133    return null
134  }
135}
136
137// With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, Claude Code refuses every plugin
138// $.http.fetch; the Python runtime (urllib) is not affected. The user asked for the judge by
139// enabling it and providing a key, so a refused fetch falls back to curl, which carries the
140// key and the body through the environment and stdin, never argv.
141const CURL_SCRIPT = 'curl -sS --max-time "$NAV_JUDGE_TIMEOUT" -X POST'
142  + ' -H "Authorization: Bearer $NAV_JUDGE_KEY" -H "Content-Type: application/json"'
143  + ' -H "User-Agent: navigator-judge/1" --data-binary @- "$NAV_JUDGE_URL"'
144
145const viaCurl = async (
146  io: Io, url: string, key: string, body: string, timeoutMs: number,
147): Promise<string | null> => {
148  if (io.runWith === undefined) return null
149  const seconds = Math.max(1, Math.ceil(timeoutMs / 1000))
150  const r = await io.runWith(['sh', '-c', CURL_SCRIPT], {
151    cwd: await io.cwd(), timeoutMs: seconds * 1000 + 500, stdin: body,
152    env: { NAV_JUDGE_KEY: key, NAV_JUDGE_URL: url, NAV_JUDGE_TIMEOUT: String(seconds) },
153  })
154  return r.exitCode === 0 && r.stdout.trim() !== '' ? r.stdout : null
155}
156
157/** One request; a Judgment, or null on any failure or timeout. Never throws. */
158export const call = async (io: Io, prompt: string, s: JudgeSettings): Promise<Judgment | null> => {
159  try {
160    const key = await apiKey(io, s)
161    if (!key || !(prompt ?? '').trim()) return null
162    const started = await io.nowMs()
163    const timeoutMs = Number(s.timeout_ms || JUDGE_DEFAULTS.timeout_ms)
164    const url = String(s.endpoint || JUDGE_DEFAULTS.endpoint)
165    const body = JSON.stringify(buildRequest(prompt, s))
166    const fetched = io.http(url, {
167      method: 'POST',
168      headers: {
169        Authorization: `Bearer ${key}`,
170        'Content-Type': 'application/json',
171        'User-Agent': 'navigator-judge/1',
172      },
173      body,
174    }).then(r => (r.ok ? r.text : null), () => viaCurl(io, url, key, body, timeoutMs))
175    const text = await Promise.race([fetched, io.sleep(timeoutMs).then(() => null)])
176    if (text === null) return null
177    const doc = JSON.parse(text) as Json
178    return parseResponse(doc, thresholdsOf(s), Math.round((await io.nowMs()) - started))
179  } catch {
180    return null
181  }
182}
183
184/** Feature-gated entry: null unless `judge.enabled`, outside Pilot. */
185export const judgePrompt = async (
186  io: Io, prompt: string, cfg: Json, pilotExecutor: boolean,
187): Promise<Judgment | null> => {
188  const s = settings(cfg)
189  if (!s.enabled || pilotExecutor) return null
190  return call(io, prompt, s)
191}
192
193export { thresholdsOf }
194
195// ---- per-event cache and telemetry (judge.for_ctx / record_call / record_axes) ---------
196
197const AXES = ['loop', 'complexity', 'task', 'ambiguity']
198const OUTCOMES = ['overridden', 'agreed', 'undecided']
199
200const section = (state: Json): Json => {
201  const block = state.judge
202  if (block !== null && typeof block === 'object' && !Array.isArray(block)) return block as Json
203  const fresh: Json = {}
204  state.judge = fresh
205  return fresh
206}
207
208const int = (v: unknown): number => (typeof v === 'number' ? Math.trunc(v) : 0)
209
210export const recordCall = (state: Json, judgment: Judgment | null, enabled: boolean): void => {
211  if (!enabled) return
212  const b = section(state)
213  b.calls = int(b.calls) + 1
214  if (judgment === null) {
215    b.failed = int(b.failed) + 1
216    return
217  }
218  b.model = judgment.model
219  b.latency_last_ms = Math.trunc(judgment.latencyMs)
220  b.latency_max_ms = Math.max(int(b.latency_max_ms), Math.trunc(judgment.latencyMs))
221}
222
223export const recordAxes = (state: Json, axes: Record<string, string> | undefined): void => {
224  if (!axes || Object.keys(axes).length === 0) return
225  const b = section(state)
226  const table = (b.axes !== null && typeof b.axes === 'object' ? b.axes : {}) as Record<string, Json>
227  b.axes = table
228  for (const [axis, outcome] of Object.entries(axes)) {
229    if (!AXES.includes(axis) || !OUTCOMES.includes(outcome)) continue
230    const row = table[axis] ?? {}
231    row[outcome] = int(row[outcome]) + 1
232    table[axis] = row
233  }
234}
235
236/** The judgment for this event, computed once and shared by gate and brief. */
237export const forCtx = async (
238  ctx: { io: Io; config: Json; pilotExecutor: boolean; state: Json; judgment?: unknown },
239  message: string,
240): Promise<Judgment | null> => {
241  if (ctx.judgment !== undefined) return ctx.judgment as Judgment | null
242  const judgment = await judgePrompt(ctx.io, message, ctx.config, ctx.pilotExecutor)
243  ctx.judgment = judgment
244  recordCall(ctx.state, judgment, Boolean(settings(ctx.config).enabled) && !ctx.pilotExecutor)
245  return judgment
246}
247
hooks/mod/lib/gen/judge-data.gen.ts 90 lines
1// GENERATED by scripts/gen_mod_data.py from the Python runtime. Do not edit.
2
3export const JUDGE_DEFAULTS = {
4  "enabled": false,
5  "provider": "typesafe",
6  "endpoint": "https://api.typesafe.ai/v1/systemone",
7  "model": "jev-latest",
8  "timeout_ms": 1500,
9  "min_confidence": 0.4,
10  "noul_low": 0.4,
11  "noul_high": 0.6,
12  "api_key_env": "TYPESAFE_API_KEY",
13  "api_key_file": "~/.config/typesafe/api_key",
14  "max_state_chars": 4000
15} as const
16
17export const QUESTIONS = {
18  "is_task": {
19    "type": "noul",
20    "instructions": "The message asks the assistant to change something: write or edit code, files, configuration, or documents. Questions, chat, status reports, pasted logs, and replies confirming or answering the assistant do not count."
21  },
22  "wants_loop": {
23    "type": "noul",
24    "instructions": "The user explicitly asks for unattended, autonomous iteration until the work is finished (for example 'run until done', 'keep going until it passes', 'do all of them without stopping'). Merely mentioning words like 'loop', 'loop mode', or 'until' in passing, in a quote, or in pasted text does not count."
25  },
26  "complexity": {
27    "type": "score",
28    "instructions": "How much work does fulfilling this request take?",
29    "criteria": [
30      "trivial: a one-line answer or a single small edit",
31      "small: one file, under an hour of work",
32      "substantial: several files, a new feature, or a refactor of one module",
33      "large: a cross-cutting refactor, a new subsystem, or multi-day work"
34    ]
35  },
36  "ambiguity": {
37    "type": "score",
38    "instructions": "How underspecified is the request, judged by whether scope, limits, and acceptance criteria are stated?",
39    "criteria": [
40      "clear: scope, target files or components, and done-criteria are explicit",
41      "partly: the goal is clear but scope, limits, or done-criteria are missing",
42      "vague: the goal itself is open to interpretation"
43    ]
44  },
45  "scope_defined": {
46    "type": "noul",
47    "instructions": "The request names the files, components, or area it applies to."
48  },
49  "limits_defined": {
50    "type": "noul",
51    "instructions": "The request states boundaries: what not to touch, a size or count limit, a time box, or constraints on the approach."
52  },
53  "approach_defined": {
54    "type": "noul",
55    "instructions": "The request says how the work should be done, not only what."
56  },
57  "verification_defined": {
58    "type": "noul",
59    "instructions": "The request states how success will be checked: tests to pass, expected output, or explicit acceptance criteria."
60  }
61} as const
62
63export const COMPLEXITY_LEVELS = [
64  "trivial: a one-line answer or a single small edit",
65  "small: one file, under an hour of work",
66  "substantial: several files, a new feature, or a refactor of one module",
67  "large: a cross-cutting refactor, a new subsystem, or multi-day work"
68] as const
69
70export const AMBIGUITY_LEVELS = [
71  "clear: scope, target files or components, and done-criteria are explicit",
72  "partly: the goal is clear but scope, limits, or done-criteria are missing",
73  "vague: the goal itself is open to interpretation"
74] as const
75
76export const AMBIGUITY_WEIGHTS = [
77  0.0,
78  0.35,
79  1.0
80] as const
81
82export const DIMENSIONS = [
83  "scope",
84  "limits",
85  "approach",
86  "verification"
87] as const
88
89export const REDACTED = "[redacted]" as const
90
hooks/mod/lib/config.ts 54 lines
1// Layered config, mirror of nav_hook_lib/config.py: DEFAULTS < shared < local.
2// isPilotExecutor() is the only PILOT_EXECUTOR policy decision in the mod.
3import { CONFIG_DEFAULTS } from './gen/config-defaults.gen'
4import type { Io, Json, OpSpec } from './types'
5
6const SHARED = '.agent/.nav-config.json'
7const LOCAL = '.agent/.nav-config.local.json'
8
9const isObject = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
10
11/** Dicts merge key-wise; scalars and lists replace wholesale; unknown keys survive. */
12export const deepMerge = (base: Json, over: Json): Json => {
13  const out: Json = { ...base }
14  for (const [k, v] of Object.entries(over)) {
15    const b = out[k]
16    out[k] = isObject(b) && isObject(v) ? deepMerge(b, v) : v
17  }
18  return out
19}
20
21export const readJson = async (io: Io, path: string): Promise<Json | null> => {
22  try {
23    const parsed: unknown = JSON.parse(await io.read(path))
24    return isObject(parsed) ? parsed : null
25  } catch {
26    return null
27  }
28}
29
30export const loadConfig = async (io: Io, root: string): Promise<Json> => {
31  const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Json
32  const shared = (await readJson(io, `${root}/${SHARED}`)) ?? {}
33  const local = (await readJson(io, `${root}/${LOCAL}`)) ?? {}
34  return deepMerge(deepMerge(defaults, shared), local)
35}
36
37/** `get(cfg, "a.b.c", fallback)`; a present null leaf is returned, not defaulted. */
38export const getPath = (cfg: unknown, dotted: string, fallback: unknown = undefined): unknown => {
39  let node: unknown = cfg
40  for (const part of dotted.split('.')) {
41    if (!isObject(node) || !(part in node)) return fallback
42    node = node[part]
43  }
44  return node
45}
46
47/** runtime._config_allows: an op with a config key runs only when `<key>.enabled` is true. */
48export const configAllows = (cfg: Json, spec: OpSpec): boolean =>
49  spec.configKey === null || getPath(cfg, `${spec.configKey}.enabled`, false) === true
50
51/** THE only PILOT_EXECUTOR policy read in the mod: any non-empty value is true (v7 parity). */
52export const isPilotExecutor = async (io: Io): Promise<boolean> =>
53  Boolean((await io.env()).PILOT_EXECUTOR)
54
hooks/mod/lib/context.ts 21 lines
1// Build the per-event context the ops see, mirror of the SimpleNamespace in runtime._dispatch.
2import { getPath, isPilotExecutor, loadConfig } from './config'
3import { projectRoot } from './project'
4import { load } from './state'
5import type { Io, Json, OpCtx } from './types'
6
7/** null outside a Navigator project or with the dispatcher kill switch off (v7 parity). */
8export const makeCtx = async (io: Io, event: string, payload: Json): Promise<OpCtx | null> => {
9  const root = await projectRoot(io)
10  if (root === null) return null
11  const config = await loadConfig(io, root)
12  if (getPath(config, 'dispatcher.enabled', true) === false) return null
13  const now = (await io.nowMs()) / 1000
14  const sessionId = await io.sessionId()
15  return {
16    io, event, payload, config, root, sessionId, now,
17    state: await load(io, root, sessionId, now),
18    pilotExecutor: await isPilotExecutor(io),
19  }
20}
21
hooks/mod/lib/life-health.ts 27 lines
1// runtime._surface_health port (TASK-84 step 5d). Not an op: the v7 dispatcher prepends this
2// line to the SessionStart contexts before the budget clamp, once per recorded dispatch error.
3// The mod runner must do the same for SessionStart (see the step 5d integration notes).
4import { cpSlice, dumps, get, isDict, pyStr, safeJson } from './life-py'
5import type { Io } from './types'
6
7export const HEALTH_FILE_NAME = '.nav-dispatch-health.json'
8const SURFACE_LINE_LIMIT = 160
9
10/** The one-line notice when an unsurfaced error exists; flips `surfaced` so it shows once. */
11export const surfaceHealth = async (io: Io, agentDir: string): Promise<string | null> => {
12  const path = `${agentDir}/${HEALTH_FILE_NAME}`
13  const doc = await safeJson(io, path)
14  if (doc === null || doc.size === 0 || get(doc, 'surfaced', true) !== false) return null
15  const last = get(doc, 'last_error')
16  if (!isDict(last)) return null
17  const line = cpSlice(`nav-dispatch: last dispatch error: ${pyStr(get(last, 'event', '?'))}/`
18    + `${pyStr(get(last, 'op', '?'))}: ${pyStr(get(last, 'error', ''))}`, 0, SURFACE_LINE_LIMIT)
19  doc.set('surfaced', true)
20  try {
21    await io.write(path, `${dumps(doc, 2)}\n`)
22  } catch {
23    // atomic_write_json returns False on failure; the line is still returned
24  }
25  return line
26}
27
hooks/mod/lib/update.ts 70 lines
1// TASK-81 point 1, mod side (TASK-84): a truthful, read-only update notice. The running
2// version is the mod's own manifest (never `claude plugin list` from inside a hook); the
3// latest stable comes from the GitHub releases API, at most once per check interval.
4import { compareVersions } from '../owns'
5import { getPath } from './config'
6import { run } from './project'
7import type { Io, Json } from './types'
8
9export const RELEASES_URL = 'https://api.github.com/repos/qf-studio/navigator/releases?per_page=10'
10export const UPDATE_COMMAND = 'claude plugin update navigator@navigator-marketplace'
11const SEMVER = /^\d+\.\d+\.\d+$/
12
13/** First non-draft, non-prerelease release whose tag is a plain semver. */
14export const latestStable = (releasesJson: string): string | null => {
15  try {
16    const list: unknown = JSON.parse(releasesJson)
17    if (!Array.isArray(list)) return null
18    for (const r of list as Json[]) {
19      if (r.draft === true || r.prerelease === true) continue
20      const v = String(r.tag_name ?? '').replace(/^v/, '')
21      if (SEMVER.test(v)) return v
22    }
23  } catch {
24    // fail silent: no notice
25  }
26  return null
27}
28
29export type UpdateSettings = { enabled: boolean; intervalHours: number; curlFallback: boolean }
30
31/**
32 * `auto_update` may be a bool (older configs) or `{enabled, check_interval_hours, curl_fallback}`.
33 * `curl_fallback` seeds off: it is outbound traffic the engine was told to refuse.
34 */
35export const updateSettings = (cfg: Json): UpdateSettings => {
36  const raw = cfg.auto_update
37  if (typeof raw === 'boolean') return { enabled: raw, intervalHours: 1, curlFallback: false }
38  return {
39    enabled: getPath(cfg, 'auto_update.enabled', true) !== false,
40    intervalHours: Number(getPath(cfg, 'auto_update.check_interval_hours', 1)) || 1,
41    curlFallback: getPath(cfg, 'auto_update.curl_fallback', false) === true,
42  }
43}
44
45export const CURL_TIMEOUT_S = 3
46
47/** The same GET as the engine fetch, as a curl argv: no shell, no secrets, URL last. */
48export const curlArgv = (url: string): string[] => [
49  'curl', '-sf', '--max-time', String(CURL_TIMEOUT_S),
50  '-H', 'Accept: application/vnd.github+json', '-H', 'User-Agent: navigator-update/1', url,
51]
52
53/**
54 * Releases JSON via curl, or null. Used only when the engine refused `$.http.fetch` (a session
55 * with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC refuses every plugin fetch) and the user opted
56 * in with `auto_update.curl_fallback: true`. A missing curl or a non-2xx answer is a quiet null.
57 */
58export const releasesViaCurl = async (io: Io, cwd: string): Promise<string | null> => {
59  const out = await run(io, curlArgv(RELEASES_URL), cwd, (CURL_TIMEOUT_S + 1) * 1000)
60  return out.trim() === '' ? null : out
61}
62
63export const dueForCheck = (lastCheckedMs: unknown, nowMs: number, intervalHours: number): boolean =>
64  typeof lastCheckedMs !== 'number' || nowMs - lastCheckedMs >= intervalHours * 3600 * 1000
65
66export const updateNotice = (installed: string, latest: string | null): string | null =>
67  latest !== null && compareVersions(latest, installed) > 0
68    ? `Navigator ${installed} installed · ${latest} available · run: ${UPDATE_COMMAND}`
69    : null
70
hooks/mod/lib/types.ts 119 lines
1// Contracts shared by the runner and the ops (TASK-84). OpResult mirrors the Python op
2// dict keys (hooks/ops/README.md) so parity tests compare like with like.
3export type Phase = 'gates' | 'responders' | 'injectors' | 'recorders'
4
5export type OpResult = {
6  additional_context?: string
7  decision?: 'block'
8  reason?: string
9  permission_decision?: 'deny' | 'ask'
10  permission_reason?: string
11  exit_code?: number
12  stderr?: string
13  system_message?: string
14  continue_?: boolean
15  /** v6 `{}` acknowledgment (TASK-61 parity); no output of its own. */
16  ack?: true
17  /**
18   * TASK-88: why a blocking result refused, for the reject log. Runtime bookkeeping, stripped
19   * by runOps before the merge; ignored on a non-blocking result.
20   */
21  reject?: { reason: string; evidence: Json }
22}
23
24export type Json = Record<string, unknown>
25
26export type EnvSnapshot = {
27  PILOT_EXECUTOR?: string
28  NAVIGATOR_CONFIG_HOME?: string
29  XDG_CONFIG_HOME?: string
30  HOME?: string
31  TYPESAFE_API_KEY?: string
32}
33
34/**
35 * The mods API, narrowed to what ops need. The engine's static scan follows `$` only into
36 * functions declared in the hooks module itself, so register.tsx builds this object (each
37 * method a direct `$.noun.method` call there) and passes it to imported modules instead.
38 */
39export type Io = {
40  pluginRoot: string
41  read: (path: string) => Promise<string>
42  write: (path: string, text: string) => Promise<void>
43  exists: (path: string) => Promise<boolean>
44  list: (path: string) => Promise<{ name: string; mtimeMs: number; kind: 'file' | 'dir' | 'other' }[]>
45  run: (argv: readonly string[], cwd: string, timeoutMs: number) =>
46    Promise<{ exitCode: number; stdout: string }>
47  cwd: () => Promise<string>
48  sessionId: () => Promise<string | null>
49  nowMs: () => Promise<number>
50  version: () => Promise<{ version: string; base?: string }>
51  env: () => Promise<EnvSnapshot>
52  setOwned: (value: string) => Promise<void>
53  http: (url: string, init: { method: string; headers: Record<string, string>; body: string }) =>
54    Promise<{ ok: boolean; status: number; text: string }>
55  sleep: (ms: number) => Promise<void>
56  /** `$.process.run` with an environment and stdin; secrets travel this way, never in argv. */
57  runWith?: (argv: readonly string[], init: {
58    cwd: string; timeoutMs: number; env: Record<string, string>; stdin: string
59  }) => Promise<{ exitCode: number; stdout: string }>
60  disowned: () => Promise<string[]>
61  noteCrash: (op: string) => Promise<number>
62  disown: (op: string) => Promise<void>
63  // ---- step 5b io ----
64  /**
65   * `$.process.run` keeping stderr (graph_sync / profile_sync diagnostics). Rejects when the
66   * command cannot start or outlives `timeoutMs`; the ops classify the rejection. Optional:
67   * without it the ops fall back to `run` and report an empty stderr.
68   */
69  runCapture?: (argv: readonly string[], cwd: string, timeoutMs: number) =>
70    Promise<{ exitCode: number; stdout: string; stderr: string }>
71  // ---- step 5b io ----
72  // ---- step 5d io ----
73  /** `$.fs.stat`: kind and mtime, or null when missing. Optional: falls back to io.list. */
74  stat?: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; mtimeMs: number } | null>
75  /** Minutes east of UTC at `ms` (Python local time). Optional: falls back to the JS Date zone. */
76  localOffsetMinutes?: (ms: number) => number
77  // ---- end step 5d io ----
78}
79
80export type OpCtx = {
81  io: Io
82  sessionId: string | null
83  /** Shared schema-2 runtime state; ops mutate it, the event saves it once. */
84  state: Json
85  /** Judge answer cached per event (undefined = not asked yet). */
86  judgment?: unknown
87  /** Per-axis outcome of this event's judgment (gate: loop, complexity; brief: task, ambiguity). */
88  judgeAxes?: Record<string, string>
89  /** Mod only: every Bash call this turn came back `isReadOnly` from Claude Code (TASK-85). */
90  bashAllReadOnly?: boolean
91  event: string
92  payload: Json
93  config: Json
94  root: string
95  pilotExecutor: boolean
96  now: number
97}
98
99export type OpSpec = {
100  name: string
101  phase: Phase
102  configKey: string | null
103  /** Optional filter on the event payload (tool name for tool events). */
104  matcher?: (payload: Json) => boolean
105}
106
107export type Op = { spec: OpSpec; run: (ctx: OpCtx) => Promise<OpResult | null> }
108
109/** What one event's ops add up to, in the mod's own vocabulary. */
110export type Merged = {
111  context: string | null
112  drop: string | null
113  deny: string | null
114  block: string | null
115  toast: string | null
116  /** Non-blocking stderr (e.g. read_guard's warn); delivered as context by the mod. */
117  notes: string | null
118}
119
hooks/mod/lib/project.ts 35 lines
1// Project root and skill-script runner shared by ops and the UI.
2import type { Io } from './types'
3
4const parentOf = (dir: string): string | null => {
5  const at = dir.lastIndexOf('/')
6  return at <= 0 ? null : dir.slice(0, at)
7}
8
9/** Nearest ancestor of `start` (default: session cwd) holding `.agent/`, as hio.project_root. */
10export const projectRoot = async (io: Io, start?: string): Promise<string | null> => {
11  let dir: string | null = start ?? (await io.cwd())
12  while (dir !== null) {
13    if (await io.exists(`${dir}/.agent`)) return dir
14    dir = parentOf(dir)
15  }
16  return null
17}
18
19/** Run a command in `cwd`; stdout on exit 0, '' on any failure (fail-open, like v7). */
20export const run = async (
21  io: Io, argv: readonly string[], cwd: string, timeoutMs = 5000,
22): Promise<string> => {
23  try {
24    const r = await io.run(argv, cwd, timeoutMs)
25    return r.exitCode === 0 ? r.stdout : ''
26  } catch {
27    return ''
28  }
29}
30
31/** Run a skill script shipped with the plugin (`skills/<skill>/functions/<file>`). */
32export const runSkill = async (
33  io: Io, script: string, args: readonly string[], cwd: string, timeoutMs = 5000,
34): Promise<string> => run(io, ['python3', `${io.pluginRoot}/skills/${script}`, ...args], cwd, timeoutMs)
35
hooks/mod/ops/index.ts 36 lines
1// Ops per Claude Code event, in registry order (hooks/nav_hook_lib/registry.py EVENT_OPS).
2// The keys are the Python event names; ops branch on ctx.event.
3import type { Op } from '../lib/types'
4import { compactMarkerPost, compactMarkerPre } from './compact_marker'
5import { configGuard } from './config_guard'
6import { failureDiagnosis } from './failure_diagnosis'
7import { graphSync } from './graph_sync'
8import { jitMemory } from './jit_memory'
9import { profileSync } from './profile_sync'
10import { promptBrief } from './prompt_brief'
11import { promptGate } from './prompt_gate'
12import { promptModes } from './prompt_modes'
13import { promptTier1 } from './prompt_tier1'
14import { readGuard } from './read_guard'
15import { sessionStart } from './session_start'
16import { setup } from './setup'
17import { stopCompletion } from './stop_completion'
18import { stopState } from './stop_state'
19import { subagentContext } from './subagent_context'
20
21export const EVENT_OPS: Record<string, readonly Op[]> = {
22  SessionStart: [sessionStart],
23  UserPromptSubmit: [promptGate, promptTier1, promptModes, promptBrief],
24  PreToolUse: [readGuard],
25  PostToolUse: [jitMemory, graphSync, profileSync],
26  Stop: [stopCompletion, stopState],
27  PreCompact: [compactMarkerPre],
28  PostCompact: [compactMarkerPost],
29  SubagentStart: [subagentContext],
30  PostToolUseFailure: [failureDiagnosis],
31  TaskCreated: [graphSync],
32  TaskCompleted: [graphSync],
33  ConfigChange: [configGuard],
34  Setup: [setup],
35}
36
hooks/mod/owns.ts 53 lines
1// Which v7 ops the mod runs in place of Python (TASK-84). The list is announced to the
2// Python runtime through NAVIGATOR_MOD_OWNS; runtime._dispatch skips every name in it.
3import type { Io } from './lib/types'
4
5/** Ops ported to the mod. An op joins this list in the same commit as its parity tests. */
6export const OWNED: readonly string[] = [
7  'prompt_gate', 'prompt_tier1', 'prompt_modes', 'prompt_brief',
8  'read_guard', 'jit_memory', 'graph_sync', 'profile_sync', 'failure_diagnosis',
9  'stop_completion', 'stop_state', 'session_start', 'compact_marker', 'subagent_context',
10  'config_guard', 'setup',
11]
12
13export const MIN_CC = '2.1.287'
14export const BREAKER_LIMIT = 3
15
16const parts = (v: string): number[] => v.split(/[.-]/).slice(0, 3).map(n => Number.parseInt(n, 10) || 0)
17
18/** -1 / 0 / 1 for dotted versions; suffixes such as `-dev` are ignored. */
19export const compareVersions = (a: string, b: string): number => {
20  const [x, y] = [parts(a), parts(b)]
21  for (let i = 0; i < 3; i += 1) {
22    const d = (x[i] ?? 0) - (y[i] ?? 0)
23    if (d !== 0) return d < 0 ? -1 : 1
24  }
25  return 0
26}
27
28export const supportsMods = async (io: Io): Promise<boolean> => {
29  const v = await io.version()
30  return compareVersions(v.base ?? v.version, MIN_CC) >= 0
31}
32
33export const ownedNow = async (io: Io): Promise<string[]> => {
34  if (!(await supportsMods(io))) return []
35  const off = await io.disowned()
36  return OWNED.filter(name => !off.includes(name))
37}
38
39export const owns = async (io: Io, name: string): Promise<boolean> =>
40  (await ownedNow(io)).includes(name)
41
42export const announce = async (io: Io): Promise<void> => {
43  await io.setOwned((await ownedNow(io)).join(','))
44}
45
46/** Count a crash; at BREAKER_LIMIT hand the op back to Python for the rest of the session. */
47export const noteCrash = async (io: Io, name: string): Promise<boolean> => {
48  if ((await io.noteCrash(name)) < BREAKER_LIMIT) return false
49  await io.disown(name)
50  await announce(io)
51  return true
52}
53