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

Finish What You Start
Sessions that last. AI that learns. Features that ship.
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.
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.
Navigator implements context engineering—load what you need, when you need it.
| Metric | Without Navigator | With Navigator |
|---|---|---|
| Tokens loaded | 150,000 | 12,000 |
| Session length | 5-7 exchanges | 20+ exchanges |
| Context at end | 95% (crashed) | 35% (comfortable) |
| Token savings | — | 92% |
Result: Sessions that actually finish what they start.
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
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.

Type /nav and a pane opens beside your session:
| Card | What it tells you |
|---|---|
| context | Fill percentage, trend, and whether this is a good moment to compact |
| session | Today's cost, tokens, cache hit rate, tokens/min |
| reads | Fan-out verdict and today's reject count |
| judge | What the typed judge decided about your last prompt, and what Navigator did |
| next | The current leg of the active task as a button that submits it, with an ETA |
| memories | Knowledge-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.
Navigator is a superset. Everything you'd expect, plus context engineering.
| Feature | Navigator | Others |
|---|---|---|
| Structured workflows | ✅ Full skill suite | ✅ |
| Component generation | ✅ | ✅ |
| Test generation | ✅ | ✅ |
| Session longevity | 20+ exchanges | 5-7 exchanges |
| Token savings | 92% verified | None |
| 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.
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".
# 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 Navigator in this project"
"Start my Navigator session"
That's it. Navigator handles the rest.
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.
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.
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.
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+.
/nav paneMIT License - See LICENSE
hooks/mod/register.tsx 949 lines1// 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}
949hooks/mod/lib/reply_modes.ts 168 lines1// 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}
168hooks/mod/lib/judge.ts 247 lines1// 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}
247hooks/mod/lib/gen/judge-data.gen.ts 90 lines1// 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
90hooks/mod/lib/config.ts 54 lines1// 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)
54hooks/mod/lib/context.ts 21 lines1// 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}
21hooks/mod/lib/life-health.ts 27 lines1// 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}
27hooks/mod/lib/update.ts 70 lines1// 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
70hooks/mod/lib/types.ts 119 lines1// 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}
119hooks/mod/lib/project.ts 35 lines1// 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)
35hooks/mod/ops/index.ts 36 lines1// 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}
36hooks/mod/owns.ts 53 lines1// 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