NODD dentro de Claude Code: los gates authorize, classify y track del protocolo ODD, apagados por defecto

Es el mod de Claude Code de NODD: los mismos gates que en pi frenan una tool antes de que corra, pero adentro de Claude Code. La lógica no está reescrita: hooks/core/ es una copia exacta del kernel puro de ../src (claude-code/sync-core.sh la genera y el test test/claude-code-core.test.ts del repo falla si se desincroniza).
Viene apagado. Instalado, no bloquea nada hasta que corras /nodd on.
Sin tocar tu configuración, para una sesión:
claude --plugin-dir ~/projects/nodd/claude-code
Siempre, junto a los otros mods (separados por :):
export CLAUDE_CODE_PLUGIN_DIRS="$CLAUDE_CODE_PLUGIN_DIRS:$HOME/projects/nodd/claude-code"
| Comando | Qué hace | ||
|---|---|---|---|
/nodd o /nodd status | Si está prendido, la declaración actual, los gates, overrides pendientes y el último rechazo | ||
/nodd on / /nodd off | El interruptor general: vale para todas las sesiones, también las que ya están abiertas. Apagado no frena nada | ||
/nodd gates | Los seis gates: prendido o apagado, quién lo decide y si ya se aplica en Claude Code | ||
| `/nodd gate <id\ | all> on\ | off` | Prende o apaga un gate en ~/.pi/nodd.json, el mismo archivo que usa pi: apagarlo acá lo apaga allá. Las demás claves del archivo (models, thinking, profiles) no se tocan |
/nodd allow <gate> [motivo] | Un override de un solo uso: lo gasta el próximo rechazo de ese gate |
| Gate | En Claude Code | |||
|---|---|---|---|---|
authorize | activo: con intent: read-only no se escribe ni se delega a un agente que pueda escribir | |||
classify | activo: no hay primera escritura sin nodd_declare | |||
track | activo: en ruta tracked/forge hace falta .nodd/<slug>/feature.md, y .nodd/** lo escribe NODD, no el modelo | |||
delegate | pendiente en Claude Code | |||
evidence | activo: nodd_task check sólo tilda si NODD vio el runner declarado salir bien después de la última escritura. Un Bash con exit distinto de 0, interrumpido, pasado a segundo plano o con su status escondido (`\ | tail, \ | \ | true, ;`) no cuenta |
promotion | pendiente en Claude Code |
Los pendientes igual observan: el kernel cuenta lecturas, escrituras y delegaciones, así que prenderlos más adelante no arranca de cero.
delegate queda pendiente a propósito. Simulado contra 30 días de sesiones reales de Claude Code con NODD prendido, habría frenado el 14% de los pedidos que escriben y el 80% de las escrituras de los subagentes, que son justamente los que escriben. Sus contadores son de toda la sesión: una sola delegación lo destraba para siempre, y sin ninguna frena todo lo que pase de 20 tool calls. Contarlos por pedido es peor (27%). Para aplicarlo acá haría falta otro diseño: sólo en la sesión principal, con umbrales más altos y una delegación que destrabe el pedido, no la sesión. El interruptor de /nodd gate delegate sigue valiendo para pi.
Prendido, el modelo tiene dos tools: nodd_declare y nodd_task (mcp__nodd__nodd_declare y mcp__nodd__nodd_task).
ODD le pide al modelo que clasifique cada pedido antes de escribir. NODD lo hace igual, pero con código:
nodd. No hace falta que lo frenen para enterarse.nodd_declare nuevo, aunque siga con el mismo slug./loop, ni lo que escribís mientras el modelo todavía está trabajando. Esos siguen bajo la declaración que había.Cuesta una llamada a nodd_declare por pedido que escribe, y seguir con la feature del pedido anterior es barato: alcanza con nodd_declare y el slug, el resto se toma de la declaración anterior. Los pedidos que sólo leen no pagan nada.
El scratchpad de la sesión no pasa por los gates. Write y Edit dentro de /tmp/claude-<uid>/<proyecto>/<id de esta sesión>/ no piden declaración, ni siquiera con intent: read-only: ahí no se cambia nada del usuario. Un Bash también, cuando NODD puede probar que todo lo que escribe cae ahí: un mkdir, touch, rm, tee o cp con destino en el scratchpad, o una redirección a un archivo de ahí, incluso a través de una variable asignada en el mismo comando (S=…/scratchpad; mkdir $S/x). Un mv, un intérprete, sed -i, una ruta relativa o un .. siguen pasando por los gates.
Con NODD prendido, un Bash irreversible se frena siempre, también dentro de un agente de forge (que no pasa por los otros gates): git reset --hard, git clean -f, git checkout -- ., git restore ., rm -rf fuera del proyecto, del home de la sesión y de /tmp, terraform destroy, kubectl delete, y DROP/TRUNCATE contra una base que no es local. Cuando hay una forma segura, el rechazo la nombra primero: git reset --keep, git stash, git clean -n. El rechazo le dice al modelo que te pida correrlo con ! <comando>, o vos lo habilitás una vez con /nodd allow destructive.
Medido sobre una semana de comandos reales: frena 45 de 17.162, todos destructivos de verdad. Una base local (-h 127.0.0.1, localhost) se puede borrar sin aviso, porque es el caso de las bases de test descartables.
Al terminar cada turno en el que se escribió algo, NODD agrega una línea debajo de la respuesta con lo que vio, no con lo que el modelo dice:
◆ NODD · auth · 3 archivos · ✓ npm test verde después de la última escritura
o, cuando algo no cierra:
◆ NODD · auth · 2 archivos · npm test sin correr después de la última escritura ⚠ la respuesta afirma «listo», pero NODD no vio npm test completo y verde después de la última escritura ⚠ src/a.test.ts: se agregó .skip( ⚠ 1 archivo fuera de lo declarado: src/extra.ts
Distingue un verde de la suite declarada, un verde parcial (npm test -- un.test.ts), una corrida roja y una corrida que escondió su exit status. Cuando el status quedó escondido (| tail, ; echo "exit=$?"), lo lee del resumen del runner o del $? impreso y lo muestra como "verde según el resumen". Un cd … && delante del runner no cuenta: cd ~/x && npm test es el runner cd /home/gon/x && npm test.
Sólo marca la afirmación que tiene contexto de tests ("pasan los tests", "suite verde", "all tests pass", "veredicto: pasa"); un "listo" genérico se marca sólo si hay runner declarado. Sin runner, cualquier corrida de tests verde observada (incluido gh pr checks) la respalda. Escribir docs, la memoria de Claude o el scratchpad después del verde no lo invalida, y un pedido que sólo tocó docs no tiene nada que verificar. Para el cierre, un Bash invalida el verde sólo si escribe un archivo de código que NODD puede nombrar: una redirección, cp, mv, tee, rm, sed -i, un open(…, 'w') de Python, git apply/merge/restore/ checkout --, o un formateador con --fix/--write. Resuelve el cd y las variables del mismo comando. Un git commit, un git checkout <rama>, una escritura a /tmp o un script que corre no lo invalidan acá. El gate evidence sigue siendo estricto: para tildar una tarea, cualquier Bash que escribe después del verde lo invalida. Avisa además cuando un test se editó entre una corrida roja y la verde, cuando se le agregaron .skip/.only/xit/t.Skip/@pytest.mark.skip o se le sacaron assertions, y cuando se escribió fuera de los files declarados. Los avisos le llegan al modelo con tu pedido siguiente. Junta lo que escribieron y corrieron la sesión principal y sus subagentes desde que empezó el pedido, incluidos los agentes de forge: no pasan por los gates, pero lo que hacen se observa, así que un VEREDICTO: pasa sin una corrida verde observada queda marcado.
/nodd allow no escribe ~/.pi/nodd-hatch.json: queda en el store del mod. Si fuera compartido, un override pedido acá lo podría gastar un rechazo de una sesión de pi abierta al mismo tiempo.forge:<fase>, o de algo que ese agente lanzó, no pasan por los gates. Un run de forge en curso no apaga NODD en el resto: la sesión principal sigue frenada./nodd allow <gate>, el comando de Claude Code. El kernel compartido escribe /nodd-allow, que es el de pi, y el mod lo traduce./nodd on|off llega a las sesiones que ya estaban abiertas sin reiniciarlas.authorize se lee el tools: del agente en .claude/agents/ del proyecto y en ~/.claude/agents/. Sin Write, Edit, MultiEdit ni NotebookEdit cuenta como de sólo lectura; sin tools: hereda todo y cuenta como escritor. Explore, Plan y claude-code-guide son de sólo lectura..nodd/. Al declarar, el documento va a la raíz del repo git donde se está trabajando en ese momento, no a la carpeta donde arrancó la sesión. Fuera de un repo, a la carpeta actual.~/.local/state/nodd/sessions/<id de sesión>.json, y NERV lee el de su propia sesión. Los archivos no se borran solos (el mod no puede borrar); son de ~1 KB cada uno../sync-core.sh # vuelve a copiar el kernel desde ../src ./sync-core.sh --check # falla si la copia no coincide con src/ claude plugin validate . --strict claude plugin test .
sync-core.sh también falla si algo del kernel importa node:* o un archivo de fuera de src/: un mod sólo puede importar sus propios archivos.
hooks/register.ts 623 lines1import { createKernel, featureDocPath, ledgerPath, type Kernel } from './core/kernel.ts'
2import { GATE_IDS, isGateId, type GateId } from './core/gates/registry.ts'
3import { grantHatch, resolveFlag, type Policy } from './core/gates/policy.ts'
4import { mergeConfig, noddConfigPath, parseConfig } from './core/config-core.ts'
5import { joinPath } from './core/path.ts'
6import { closeReport, isTestFile, testEditSignals, type TestEdit } from './core/close-report.ts'
7import type { Committed } from './core/state.ts'
8import { destructiveReason } from './core/destructive.ts'
9import { writesOnlyUnder } from './core/scratch.ts'
10import {
11 BUILTIN_AGENTS,
12 CC_DECLARE_SCHEMA,
13 CC_FLAGS,
14 CC_TASK_SCHEMA,
15 ENFORCED,
16 MAIN,
17 PROTOCOL,
18 REMINDER,
19 agentReadOnly,
20 bashOutcome,
21 ccReason,
22 continueDeclaration,
23 gateOfReason,
24 inScratchpad,
25 isForgeType,
26 isNewRequest,
27 normalizeCall,
28 snapshot,
29 stagedFs,
30 type Call,
31 type NoddNames,
32 type Refusal,
33} from './cc.ts'
34
35const names: NoddNames = { declare: 'mcp__nodd__nodd_declare', task: 'mcp__nodd__nodd_task' }
36const fs = stagedFs()
37const kernels = new Map<string, Kernel>()
38const agents = new Map<string, { type: string; parent: string }>()
39const capabilities = new Map<string, boolean | null>()
40const lastDeclared = new Map<string, Record<string, unknown>>()
41let requestStartAt = ''
42let lastTick = 0
43let allowDestructive = false
44
45function tick(): string {
46 lastTick = Math.max(Date.now(), lastTick + 1)
47 return new Date(lastTick).toISOString()
48}
49let testEdits: Array<Omit<TestEdit, 'seq'> & { at: string }> = []
50let pendingNote: string | null = null
51let lastReported = -1
52
53let booted: Promise<void> | undefined
54let home = ''
55let cwd = ''
56let sessionId = ''
57let projectRoot = ''
58let headless = false
59let enabled = false
60let hatches: Policy['hatches'] = {}
61let toolsRegistered = false
62let lastRefusal: Refusal | null = null
63let refusals = 0
64let lastExport = ''
65
66const configPath = () => noddConfigPath(home)
67const switchPath = () => joinPath(home, '.local', 'state', 'nodd', 'switch.json')
68
69async function readSwitch($: any): Promise<boolean | undefined> {
70 const raw = home ? await readText($, switchPath()) : null
71 if (raw === null) return undefined
72 try {
73 const v = JSON.parse(raw)?.enabled
74 return typeof v === 'boolean' ? v : undefined
75 } catch {
76 return undefined
77 }
78}
79
80async function writeSwitch($: any, value: boolean) {
81 await $.store.set('enabled', value).catch(() => undefined)
82 if (home) await $.fs.write(switchPath(), JSON.stringify({ enabled: value, at: new Date().toISOString() }) + '\n').catch(() => undefined)
83}
84
85const statePath = () => joinPath(home, '.local', 'state', 'nodd', 'sessions', `${sessionId || 'sin-id'}.json`)
86const rootDir = () => projectRoot || cwd
87
88async function readText($: any, path: string): Promise<string | null> {
89 try {
90 const text = await $.fs.read(path)
91 return typeof text === 'string' ? text : null
92 } catch {
93 return null
94 }
95}
96
97function boot($: any, sessionCwd?: string): Promise<void> {
98 booted ??= (async () => {
99 home = (await $.env.get('HOME').catch(() => '')) || ''
100 headless = (await $.env.get('CLAUDE_CODE_ENTRYPOINT').catch(() => '')) === 'sdk-cli'
101 cwd = sessionCwd || (await $.session.cwd().catch(() => '')) || ''
102 sessionId = (await $.session.id().catch(() => '')) || ''
103 enabled = (await readSwitch($)) ?? (await $.store.get('enabled').catch(() => undefined)) === true
104 const saved = await $.store.get('hatches').catch(() => undefined)
105 hatches = saved && typeof saved === 'object' && !Array.isArray(saved) ? (saved as Policy['hatches']) : {}
106 await restoreSession($)
107 })()
108 return booted
109}
110
111async function syncEnabled($: any) {
112 const stored = (await readSwitch($)) ?? (await $.store.get('enabled').catch(() => enabled)) === true
113 if (stored === enabled) return
114 enabled = stored
115 if (enabled) await registerTools($)
116 refreshContext($)
117 await exportState($)
118}
119
120async function resolveRoot($: any): Promise<string> {
121 const repo = await $.session.repo().catch(() => null)
122 if (repo && typeof repo.root === 'string' && repo.root !== '') return repo.root
123 return (await $.session.cwd().catch(() => '')) || cwd
124}
125
126function configGates(): Policy['config'] {
127 const text = fs.existsSync(configPath()) ? fs.readFileSync(configPath(), 'utf8') : undefined
128 return parseConfig(text).config.gates
129}
130
131function loadPolicy(): Policy {
132 return { config: configGates(), flags: { ...CC_FLAGS }, hatches: { ...hatches } }
133}
134
135function kernelFor(key: string): Kernel {
136 const existing = kernels.get(key)
137 if (existing) return existing
138 const kernel = createKernel({
139 fs,
140 cwd: rootDir,
141 now: tick,
142 loadPolicy,
143 isReadOnlyAgent: (name) => capabilities.get(name.toLowerCase()) ?? null,
144 spendHatch: (gate) => {
145 const next = { ...hatches }
146 delete next[gate]
147 hatches = next
148 },
149 })
150 if (key !== MAIN) {
151 const parent = kernels.get(agents.get(key)?.parent ?? MAIN)?.state.committed.declaration
152 if (parent) kernel.state.committed = { ...kernel.state.committed, declaration: { ...parent, files: [...parent.files], seq: 0 } }
153 }
154 kernels.set(key, kernel)
155 return kernel
156}
157
158async function stage($: any, paths: string[]) {
159 for (const path of new Set(paths)) fs.seed(path, await readText($, path))
160}
161
162async function flush($: any): Promise<string[]> {
163 const problems: string[] = []
164 for (const [path, text] of fs.drain()) {
165 try {
166 await $.fs.write(path, text)
167 const back = await readText($, path)
168 if (back !== text) problems.push(`read-back mismatch on ${path}`)
169 } catch (err) {
170 problems.push(`${path}: ${err instanceof Error ? err.message : String(err)}`)
171 }
172 }
173 return problems
174}
175
176function testEditTexts(call: Call, before: string | null): [string, string] | null {
177 const input = call.input as any
178 if (Array.isArray(input.edits)) return [input.edits.map((x: any) => String(x?.old_string ?? '')).join('\n'), input.edits.map((x: any) => String(x?.new_string ?? '')).join('\n')]
179 if (typeof input.new_string === 'string') return [String(input.old_string ?? ''), input.new_string]
180 if (typeof input.content === 'string') return [before ?? '', input.content]
181 return null
182}
183
184function requestView(): { committed: Committed; edits: TestEdit[]; size: number } {
185 const events: Array<{ at: string; write?: string; run?: Committed['commandResults'][number] }> = []
186 let size = 0
187 for (const kernel of kernels.values()) {
188 size += kernel.state.committed.toolCalls
189 for (const [path, write] of kernel.state.committed.filesWritten) if (write.at >= requestStartAt) events.push({ at: write.at, write: path })
190 for (const run of kernel.state.committed.commandResults) if (run.at >= requestStartAt) events.push({ at: run.at, run })
191 }
192 events.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
193 const filesWritten = new Map<string, { at: string; seq: number }>()
194 const commandResults: Committed['commandResults'] = []
195 events.forEach((event, i) => {
196 if (event.write) filesWritten.set(event.write, { at: event.at, seq: i + 1 })
197 if (event.run) commandResults.push({ ...event.run, seq: i + 1 })
198 })
199 const seqAt = (at: string) => events.filter((event) => event.at <= at).length
200 const committed: Committed = {
201 seen: new Set(),
202 filesRead: new Set(),
203 filesWritten,
204 commandResults,
205 delegations: 0,
206 toolCalls: events.length,
207 declaration: kernels.get(MAIN)?.state.committed.declaration ?? null,
208 }
209 const edits = testEdits.map(({ at, ...edit }) => ({ ...edit, seq: seqAt(at) }))
210 return { committed, edits, size }
211}
212
213function closingText(answer: string): string | null {
214 const view = requestView()
215 if (view.size === lastReported) return null
216 const declaration = view.committed.declaration
217 const report = closeReport({
218 committed: view.committed,
219 since: 0,
220 slug: declaration?.slug ?? null,
221 runner: declaration?.runner ?? null,
222 declaredFiles: declaration?.files ?? [],
223 answer,
224 testEdits: view.edits,
225 })
226 if (!report) return null
227 lastReported = view.size
228 pendingNote = report.warnings.length > 0 ? `NODD, cierre del turno anterior: ${report.warnings.join(' | ')}` : null
229 return [report.line, ...report.warnings.map((w) => ` ⚠ ${w}`)].join('\n')
230}
231
232function clearDeclaration() {
233 const main = kernels.get(MAIN)
234 if (main?.state.committed.declaration) main.state.committed = { ...main.state.committed, declaration: null }
235}
236
237function refreshContext($: any) {
238 try {
239 $.ui.invalidate('prompt.context')
240 } catch {}
241}
242
243async function saveHatches($: any) {
244 await $.store.set('hatches', hatches).catch(() => undefined)
245}
246
247async function agentOf($: any, id: string): Promise<{ type: string; parent: string } | undefined> {
248 const known = agents.get(id)
249 if (known) return known
250 const info = ((await $.agent.list().catch(() => [])) as any[]).find((a: any) => a.id === id)
251 if (!info) return undefined
252 const entry = { type: String(info.type ?? ''), parent: info.parentId ?? MAIN }
253 agents.set(id, entry)
254 return entry
255}
256
257async function insideForge($: any, e: any, key: string): Promise<boolean> {
258 if ((e.tool === 'Agent' || e.tool === 'Task') && isForgeType(e.subagent_type)) return true
259 let id = key
260 for (let depth = 0; id !== MAIN && depth < 8; depth++) {
261 const info = await agentOf($, id)
262 if (!info) break
263 if (isForgeType(info.type)) return true
264 id = info.parent
265 }
266 return false
267}
268
269async function agentFiles($: any, dir: string): Promise<string[]> {
270 const entries = ((await $.fs.list(dir).catch(() => [])) as any[]).filter((f: any) => String(f.name).endsWith('.md'))
271 const texts: string[] = []
272 for (const entry of entries) {
273 const text = await readText($, joinPath(dir, String(entry.name)))
274 if (text !== null) texts.push(text)
275 }
276 return texts
277}
278
279async function resolveCapability($: any, name: unknown) {
280 if (typeof name !== 'string' || name === '') return
281 const key = name.toLowerCase()
282 if (key in BUILTIN_AGENTS) {
283 capabilities.set(key, BUILTIN_AGENTS[key] ?? null)
284 return
285 }
286 let found: boolean | null = null
287 for (const dir of [joinPath(cwd, '.claude', 'agents'), joinPath(home, '.claude', 'agents')]) {
288 for (const text of await agentFiles($, dir)) {
289 const answer = agentReadOnly(text, name)
290 if (answer !== null) {
291 found = answer
292 break
293 }
294 }
295 if (found !== null) break
296 }
297 capabilities.set(key, found)
298}
299
300function pathsFor(kernel: Kernel, call: Call): string[] {
301 const paths = [configPath()]
302 const declared = kernel.state.committed.declaration?.slug
303 if (declared) paths.push(featureDocPath(rootDir(), declared))
304 const slug = call.input.slug
305 if ((call.toolName === 'nodd_declare' || call.toolName === 'nodd_task') && typeof slug === 'string' && slug !== '') {
306 paths.push(featureDocPath(rootDir(), slug), ledgerPath(rootDir(), slug))
307 }
308 return paths
309}
310
311async function restoreSession($: any) {
312 if (!sessionId) return
313 const saved = await $.store.get(`session:${sessionId}`).catch(() => undefined)
314 if (!saved || typeof saved !== 'object') return
315 if (typeof saved.projectRoot === 'string') projectRoot = saved.projectRoot
316 if (typeof saved.requestStartAt === 'string') requestStartAt = saved.requestStartAt
317 if (saved.lastDeclared && typeof saved.lastDeclared === 'object') lastDeclared.set(MAIN, saved.lastDeclared)
318 if (saved.declaration && typeof saved.declaration === 'object') {
319 const main = kernelFor(MAIN)
320 main.state.committed = { ...main.state.committed, declaration: { ...saved.declaration, seq: 0 } }
321 }
322}
323
324let lastSaved = ''
325
326async function saveSession($: any) {
327 if (!sessionId) return
328 const session = {
329 declaration: kernels.get(MAIN)?.state.committed.declaration ?? null,
330 lastDeclared: lastDeclared.get(MAIN) ?? null,
331 projectRoot,
332 requestStartAt,
333 }
334 const text = JSON.stringify(session)
335 if (text === lastSaved) return
336 lastSaved = text
337 await $.store.set(`session:${sessionId}`, session).catch(() => undefined)
338}
339
340async function exportState($: any) {
341 await saveSession($)
342 if (headless || !home) return
343 await stage($, [configPath()])
344 const snap = snapshot({
345 sessionId,
346 enabled,
347 config: configGates(),
348 committed: kernels.get(MAIN)?.state.committed ?? null,
349 lastRefusal,
350 refusals,
351 agents: [...kernels.keys()].filter((k) => k !== MAIN).length,
352 })
353 const text = JSON.stringify(snap, null, 2) + '\n'
354 if (text === lastExport) return
355 lastExport = text
356 await $.fs.write(statePath(), text).catch(() => undefined)
357}
358
359async function registerTools($: any) {
360 if (toolsRegistered) return
361 try {
362 const declare = await $.tool.register({ name: 'nodd_declare', description: CC_DECLARE_SCHEMA.description, inputSchema: CC_DECLARE_SCHEMA.parameters })
363 const task = await $.tool.register({ name: 'nodd_task', description: CC_TASK_SCHEMA.description, inputSchema: CC_TASK_SCHEMA.parameters })
364 if (typeof declare?.tool === 'string') names.declare = declare.tool
365 if (typeof task?.tool === 'string') names.task = task.tool
366 toolsRegistered = true
367 } catch (err) {
368 $.ui.log(`nodd: no pude registrar las tools: ${err}`, { to: 'debug' })
369 }
370}
371
372async function serve($: any, key: string, call: Call, id: string) {
373 if (call.toolName === 'nodd_declare') {
374 const continued = continueDeclaration(call.input, lastDeclared.get(key) ?? lastDeclared.get(MAIN))
375 if ('problem' in continued) return { deny: continued.problem }
376 call = { ...call, input: continued.input }
377 projectRoot = await resolveRoot($)
378 }
379 const kernel = kernelFor(key)
380 await stage($, pathsFor(kernel, call))
381 kernel.onToolCall({ toolName: call.toolName, toolCallId: id, input: call.input })
382 const reply = call.toolName === 'nodd_declare' ? kernel.declare(call.input as any) : kernel.task(call.input as any)
383 const problems = await flush($)
384 const final = problems.length === 0 ? reply : { ok: false, text: `${reply.text}\nnodd: ${problems.join('; ')}` }
385 kernel.onToolResult({ toolName: call.toolName, toolCallId: id, input: call.input, isError: !final.ok, content: final.text })
386 if (final.ok && call.toolName === 'nodd_declare') lastDeclared.set(key, call.input)
387 await exportState($)
388 return final.ok ? { result: final.text } : { deny: ccReason(final.text) }
389}
390
391async function readRawConfig($: any): Promise<{ raw: Record<string, unknown> } | { problem: string }> {
392 const text = await readText($, configPath())
393 if (text === null || text.trim() === '') return { raw: {} }
394 try {
395 const raw = JSON.parse(text)
396 if (raw && typeof raw === 'object' && !Array.isArray(raw)) return { raw }
397 return { problem: `${configPath()} no es un objeto JSON` }
398 } catch (err) {
399 return { problem: `${configPath()} no es JSON válido (${err instanceof Error ? err.message : String(err)})` }
400 }
401}
402
403async function setGates($: any, ids: readonly GateId[], value: boolean): Promise<string | null> {
404 const read = await readRawConfig($)
405 if ('problem' in read) return `nodd: no toco la config: ${read.problem}. Arreglala a mano primero.`
406 const gates = { ...((read.raw.gates as Record<string, unknown> | undefined) ?? {}) }
407 for (const id of ids) gates[id] = { enabled: value }
408 await $.fs.write(configPath(), `${JSON.stringify(mergeConfig(read.raw, { gates }), null, 2)}\n`)
409 return null
410}
411
412function gateLines(config: Policy['config']): string[] {
413 const policy: Policy = { config, flags: {}, hatches: {} }
414 const width = Math.max(...GATE_IDS.map((id) => id.length))
415 return GATE_IDS.map((id) => {
416 const { enabled: on, source } = resolveFlag(id, policy)
417 const where = ENFORCED.includes(id) ? 'activo en Claude Code' : 'pendiente en Claude Code'
418 return ` ${id.padEnd(width)} ${(on ? 'on' : 'off').padEnd(3)} ${source.padEnd(7)} ${where}`
419 })
420}
421
422async function statusText($: any): Promise<string> {
423 await stage($, [configPath()])
424 const declaration = kernels.get(MAIN)?.state.committed.declaration
425 const pending = Object.keys(hatches)
426 const lines = [
427 `nodd ${enabled ? 'encendido' : 'apagado'} en Claude Code${enabled ? '' : ' (no bloquea nada)'}`,
428 `declaración: ${declaration ? `${declaration.intent} · ${declaration.route} · ${declaration.slug}` : 'ninguna'}`,
429 'gates (compartidos con pi en ~/.pi/nodd.json):',
430 ...gateLines(configGates()),
431 `overrides pendientes: ${pending.length ? pending.join(', ') : 'ninguno'}`,
432 `último rechazo: ${lastRefusal ? `${lastRefusal.gate} · ${lastRefusal.reason}` : 'ninguno'}`,
433 ]
434 const raw = await readRawConfig($)
435 if ('problem' in raw) lines.push(`ojo: ${raw.problem}; todos los gates quedan en su default hasta arreglarlo`)
436 return lines.join('\n')
437}
438
439async function command($: any, args: string): Promise<string> {
440 const [verb = 'status', ...rest] = args.trim().split(/\s+/).filter((w) => w !== '')
441 switch (verb.toLowerCase()) {
442 case 'on':
443 enabled = true
444 await writeSwitch($, true)
445 await registerTools($)
446 refreshContext($)
447 await exportState($)
448 return `nodd: encendido. Gates activos en Claude Code: ${ENFORCED.join(', ')}.`
449 case 'off':
450 enabled = false
451 await writeSwitch($, false)
452 refreshContext($)
453 await exportState($)
454 return 'nodd: apagado. No bloquea nada.'
455 case 'status':
456 return statusText($)
457 case 'gates':
458 await stage($, [configPath()])
459 return ['nodd gates (gate, modo, quién decide, en Claude Code):', ...gateLines(configGates())].join('\n')
460 case 'gate': {
461 const [id, value] = rest
462 if ((value !== 'on' && value !== 'off') || !id || (id !== 'all' && !isGateId(id))) {
463 return `uso: /nodd gate <${GATE_IDS.join('|')}|all> on|off`
464 }
465 const ids = id === 'all' ? GATE_IDS : [id as GateId]
466 const problem = await setGates($, ids, value === 'on')
467 if (problem) return problem
468 await exportState($)
469 return `nodd: gate ${id} ${value === 'on' ? 'prendido' : 'apagado'}.`
470 }
471 case 'allow': {
472 const [gate, ...why] = rest
473 if (gate === 'destructive') {
474 allowDestructive = true
475 return 'nodd: override de un solo uso para un comando irreversible. Lo consume el próximo.'
476 }
477 if (!gate || !isGateId(gate)) return `uso: /nodd allow <gate> [motivo]. Gates: ${GATE_IDS.join(', ')}`
478 if (!ENFORCED.includes(gate)) return `nodd: ${gate} todavía no se aplica en Claude Code, así que no hay nada que saltear.`
479 hatches = grantHatch({ config: {}, flags: {}, hatches }, gate, why.join(' '), new Date().toISOString()).hatches
480 await saveHatches($)
481 await exportState($)
482 return `nodd: override de un solo uso para ${gate}. Lo consume el próximo rechazo de ${gate}.`
483 }
484 default:
485 return 'uso: /nodd [status|on|off|gates|gate <id|all> on|off|allow <gate> [motivo]]'
486 }
487}
488
489export function register(on: any) {
490 on('session.start', async ($: any, e: any, next: any) => {
491 const r = await next(e)
492 await boot($, e?.cwd)
493 if (enabled) await registerTools($)
494 await $.command
495 .register({
496 name: 'nodd',
497 description: 'NODD en Claude Code: gates authorize, classify y track (apagado por defecto)',
498 argumentHint: 'status | on | off | gates | gate <id|all> on|off | allow <gate> [motivo]',
499 immediate: true,
500 })
501 .catch((err: any) => $.ui.log(`nodd: /nodd no registrado: ${err}`, { to: 'debug' }))
502 await exportState($)
503 return r
504 })
505
506 on('command.run', { command: 'nodd' }, async ($: any, e: any) => {
507 await boot($)
508 await syncEnabled($)
509 return { text: (await command($, String(e.args ?? ''))).replace(/^nodd: /, '') }
510 })
511
512 on('prompt.context', async ($: any, e: any, next: any) => {
513 const r = await next(e)
514 await boot($)
515 await syncEnabled($)
516 if (!enabled) return r
517 return { ...r, blocks: [...(r?.blocks ?? []), { name: 'nodd', text: PROTOCOL }] }
518 })
519
520 on('prompt.submit', async ($: any, e: any, next: any) => {
521 await boot($)
522 await syncEnabled($)
523 if (!enabled) return next(e)
524 if (!isNewRequest(e)) {
525 const fromUser = ['composer', 'bridge', 'sdk'].includes(String(e?.origin?.kind))
526 if (!fromUser || !pendingNote) return next(e)
527 const note = pendingNote
528 pendingNote = null
529 return next({ ...e, context: [...(e.context ?? []), note] })
530 }
531 clearDeclaration()
532 requestStartAt = tick()
533 testEdits = []
534 const note = pendingNote
535 pendingNote = null
536 await exportState($)
537 return next({ ...e, context: [...(e.context ?? []), ...(note ? [note] : []), REMINDER] })
538 })
539
540 on('turn.complete', async ($: any, e: any, next: any) => {
541 const r = await next(e)
542 if (!enabled || e?.agentId || e?.isAborted) return r
543 const text = closingText(String(e?.answer ?? ''))
544 return text ? { ...r, text } : r
545 })
546
547 on('agent.spawn', async ($: any, e: any, next: any) => {
548 const r = await next(e)
549 if (r?.agentId) agents.set(r.agentId, { type: String(e.subagentType ?? ''), parent: e.parentAgentId ?? MAIN })
550 return r
551 })
552
553 on('tool.call', async ($: any, e: any, next: any) => {
554 await boot($)
555 await syncEnabled($)
556 const isNodd = e.tool === names.declare || e.tool === names.task
557 if (!enabled && !isNodd) return next(e)
558 const key: string = e.agentId ?? MAIN
559 const id = String(e.tool_use_id ?? `nodd-${Date.now()}`)
560 const call = normalizeCall(String(e.tool), e, names)
561 if (isNodd) return serve($, key, call, id)
562 if ((call.toolName === 'write' || call.toolName === 'edit') && inScratchpad(call.input.file_path ?? call.input.path, sessionId)) return next(e)
563 if (call.toolName === 'bash') {
564 const harm = destructiveReason(String(call.input.command ?? ''), [rootDir(), cwd, '/tmp'], home)
565 if (harm && allowDestructive) allowDestructive = false
566 else if (harm) {
567 refusals += 1
568 const reason = `nodd/destructive: ${harm}, and NODD never runs that on the model's behalf, not even inside forge. To proceed: use the safer command named above if there is one, or, if the user wants exactly this, ask them to run it themselves with \`! ${String(call.input.command ?? '').split('\n')[0]}\`, or to allow it once with /nodd allow destructive`
569 lastRefusal = { gate: 'destructive', reason, at: new Date().toISOString() }
570 await exportState($)
571 return { deny: reason }
572 }
573 }
574 if (call.toolName === 'bash' && writesOnlyUnder(String(call.input.command ?? ''), (path) => inScratchpad(path, sessionId))) return next(e)
575 const forge = await insideForge($, e, key)
576 const kernel = kernelFor(key)
577
578 let decision: { block: true; reason: string } | null = null
579 if (!forge) {
580 await stage($, pathsFor(kernel, call))
581 if (call.toolName === 'subagent') await resolveCapability($, call.input.agent)
582 const before = JSON.stringify(hatches)
583 try {
584 kernel.reloadPolicy()
585 decision = kernel.checkCall(call)
586 } catch {
587 decision = null
588 }
589 if (JSON.stringify(hatches) !== before) await saveHatches($)
590 await flush($)
591 }
592 kernel.onToolCall({ toolName: call.toolName, toolCallId: id, input: call.input })
593 if (decision) {
594 kernel.forgetPending(id)
595 refusals += 1
596 const reason = ccReason(decision.reason)
597 lastRefusal = { gate: gateOfReason(reason), reason, at: new Date().toISOString() }
598 await exportState($)
599 return { deny: reason }
600 }
601
602 const path = call.input.file_path ?? call.input.path
603 const testPath = (call.toolName === 'write' || call.toolName === 'edit') && typeof path === 'string' && isTestFile(path) ? path : null
604 const prior = testPath && typeof call.input.content === 'string' ? await readText($, testPath) : null
605 let r: any
606 try {
607 r = await next(e)
608 } catch (err) {
609 kernel.forgetPending(id)
610 throw err
611 }
612 if (r && typeof r.deny === 'string') kernel.forgetPending(id)
613 else {
614 const seen = call.toolName === 'bash' ? bashOutcome(r) : { isError: r?.isError === true, content: String(r?.text ?? '') }
615 kernel.onToolResult({ toolName: call.toolName, toolCallId: id, input: call.input, ...seen })
616 const texts = testPath && !seen.isError ? testEditTexts(call, prior) : null
617 if (texts) testEdits.push({ path: testPath!, at: kernel.state.committed.filesWritten.get(testPath!)?.at ?? new Date().toISOString(), ...testEditSignals(texts[0], texts[1]) })
618 }
619 await exportState($)
620 return r
621 })
622}
623hooks/core/kernel.ts 666 lines1import { observation, pendingCall, type Observation } from "./observations.ts";
2import { emptyState, fold, type Committed, type NoddState } from "./state.ts";
3import {
4 emptyDoc,
5 parseFeatureDoc,
6 renderFeatureDoc,
7 ROUTES,
8 type FeatureDoc,
9 type Intent,
10 type Route,
11} from "./feature-doc.ts";
12import { writeVerified, type Fs } from "./io-core.ts";
13import { consumeHatch, emptyPolicy, type GateDecision, type Policy } from "./gates/policy.ts";
14import { hatchPath, parseConfig, noddConfigPath } from "./config-core.ts";
15import { GATE_IDS } from "./gates/registry.ts";
16import { authorizeGate, type CapabilityLookup } from "./gates/authorize.ts";
17import { classifyGate } from "./gates/classify.ts";
18import { trackGate } from "./gates/track.ts";
19import { delegateGate } from "./gates/delegate.ts";
20import { evidenceGate, isDeclaredRunner, masksExitStatus, writesByBash } from "./gates/evidence.ts";
21import { promotionGate } from "./gates/promotion.ts";
22import { isDeclaredPath, isFileWrite, targetPath, type GateRequest } from "./gates/request.ts";
23import { appendRecord, readLedger } from "./ledger-core.ts";
24import { isSuccess, parseOutcome } from "./outcome.ts";
25import { candidateFor, candidateIdentity } from "./review-candidate.ts";
26import { reopenTask } from "./change-acceptance.ts";
27import type { TaskProgress } from "./status.ts";
28import { joinPath } from "./path.ts";
29
30/** The NODD entry type appended to the session so a reload can rebuild state. */
31export const OBSERVATION_ENTRY = "nodd:observation";
32
33export type ToolCallEvent = { toolName: string; toolCallId: string; input?: Record<string, unknown> };
34export type ToolResultEvent = ToolCallEvent & { isError?: boolean; content?: unknown };
35/**
36 * pi's `session_start` carries no entries (`types.d.ts:416-422`): the session log
37 * is reached through the context, and custom entries arrive as
38 * `{ type: "custom", customType, data }` (`session-manager.d.ts:69-73`).
39 */
40export type SessionEntry = { type?: string; customType?: string; data?: unknown };
41
42const INTENTS: readonly Intent[] = ["read-only", "change"];
43
44/** `.nodd/<slug>/feature.md` — the durable, extension-owned NODD artifact. */
45function anchorPath(input: Record<string, unknown>, cwd: string): Record<string, unknown> {
46 const key = typeof input.file_path === "string" && input.file_path !== "" ? "file_path" : "path";
47 const path = input[key];
48 if (typeof path !== "string" || path === "" || path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return input;
49 return { ...input, [key]: joinPath(cwd, path) };
50}
51
52export function featureDocPath(cwd: string, slug: string): string {
53 return joinPath(cwd, ".nodd", slug, "feature.md");
54}
55
56export type ToolReply = { ok: boolean; text: string };
57
58export type DeclareArgs = {
59 intent: Intent;
60 route: Route;
61 slug: string;
62 summary: string;
63 title?: string;
64 /** Read by `/nodd-promote` into the forge handoff. Optional: a small fix owes no problem statement. */
65 problem?: string;
66 scope?: string;
67 constraints?: string;
68 /** The verification command. `gate-evidence` accepts runs of this and nothing else. */
69 runner?: string;
70 tdd?: "strict" | "off";
71 /** The files this work says it will touch. `gate-promotion` compares against it. */
72 files?: string[];
73};
74
75export type TaskArgs = {
76 action: "add" | "check" | "reopen";
77 id: string;
78 title?: string;
79 slug: string;
80 /** Required to reopen a checked task (`routing.go:97`). */
81 reason?: string;
82};
83
84function resultText(content: unknown): string {
85 if (typeof content === "string") return content;
86 if (Array.isArray(content)) {
87 return content.map((part) => (typeof part === "string" ? part : String((part as { text?: string })?.text ?? ""))).join("\n");
88 }
89 return content === undefined || content === null ? "" : String(content);
90}
91
92export type Kernel = {
93 state: NoddState;
94 /**
95 * Record a classification. On a `tracked`/`forge` route it creates the
96 * feature doc and returns ODD's one-line report (`routing.go:49`). The model
97 * supplies fields, never document text: a doc the model can author is a doc
98 * the model can forge, and then evidence is prose again.
99 */
100 declare(args: DeclareArgs): ToolReply;
101 /** Add or check off a task. The extension renders the doc. */
102 task(args: TaskArgs): ToolReply;
103 onToolCall(event: ToolCallEvent): void;
104 /** Drop a call that produced no effect, because pi reports no result for it. */
105 forgetPending(toolCallId: string): void;
106 /** Drop every in-flight call at a turn boundary, where none can still report. */
107 forgetAllPending(): void;
108 onToolResult(event: ToolResultEvent): Observation;
109 /**
110 * Rebuild what a new process can legitimately know from a previous one.
111 *
112 * Everything a gate needs for *context* comes back: the declaration, the files
113 * read and written, the delegation and tool-call counters. What deliberately
114 * does **not** come back is evidence — `commandResults`, which is what
115 * `gate-evidence` reads.
116 *
117 * Round 1 replayed those too, so a resumed process with zero commands run
118 * checked tasks off while the README promised the opposite. The entries are
119 * not forgeable by the model — the kernel writes them from real tool results,
120 * and `gate-track` refuses model writes into `.nodd/` — but "not forged" is a
121 * weaker claim than "observed here", and evidence is the one place NODD
122 * insists on the stronger one. A run this process did not see is a report
123 * about the past; the remedy is one command.
124 */
125 replay(entries: SessionEntry[] | undefined): void;
126 /**
127 * The evidence view. It returns `Committed` — not `NoddState` — so a gate
128 * reading evidence physically cannot see a sibling call that has not
129 * finished. The hazard is made unrepresentable rather than documented.
130 */
131 evidenceView(): Committed;
132 /** Checked-vs-total for the declared slug, or null when there is no task list. */
133 taskProgress(): TaskProgress | null;
134 /** Run the ordered registry against one call. `null` means let it run. */
135 checkCall(request: GateRequest): { block: true; reason: string } | null;
136 setPolicy(policy: Policy): void;
137 /** Re-read the configured flags, keeping runtime flags and hatches. */
138 reloadPolicy(): Policy;
139 policy(): Policy;
140};
141
142
143export type KernelOptions = {
144 fs: Fs;
145 /** A function when the project root is only known later, e.g. once a declaration names it. */
146 cwd: string | (() => string);
147 now?: () => string;
148 loadPolicy?: () => Policy;
149 isReadOnlyAgent?: CapabilityLookup;
150 // Injected like `docExists` and `isReadOnlyAgent`: the kernel decides a hatch
151 // was spent, the caller owns where that fact is persisted.
152 spendHatch?: (gate: string) => void;
153};
154
155export function createKernel(options: KernelOptions): Kernel {
156 const { fs } = options;
157 const root = typeof options.cwd === "function" ? options.cwd : () => options.cwd as string;
158 const now = options.now ?? (() => new Date().toISOString());
159 const loadPolicy = options.loadPolicy ?? emptyPolicy;
160 const isReadOnlyAgent = options.isReadOnlyAgent ?? (() => null);
161 const spendHatch = options.spendHatch ?? (() => {});
162 const state = emptyState();
163 // Mutable because `/nodd-allow` grants a hatch mid-session and a refusal
164 // spends it. The policy is session state, not a constant.
165 let policy: Policy = emptyPolicy();
166 // Set by `setPolicy`: the caller owns the whole policy from then on, and
167 // `reloadPolicy` stops re-reading the file over their choice.
168 let policyIsPinned = false;
169
170 const readDoc = (slug: string): FeatureDoc | null => {
171 const path = featureDocPath(root(), slug);
172 if (!fs.existsSync(path)) return null;
173 return parseFeatureDoc(fs.readFileSync(path, "utf8")).doc;
174 };
175
176 const saveDoc = (doc: FeatureDoc): ToolReply => {
177 const path = featureDocPath(root(), doc.slug);
178 const previous = fs.existsSync(path) ? fs.readFileSync(path, "utf8") : undefined;
179 const result = writeVerified(path, renderFeatureDoc(doc), { fs, expectedPrevious: previous });
180 if (!result.ok) return { ok: false, text: `nodd: ${result.limitation}` };
181 const conflict = result.conflictPath ? ` (previous version preserved at ${result.conflictPath})` : "";
182 return { ok: true, text: `.nodd/${doc.slug}/feature.md created with ${doc.tasks.length} tasks${conflict}` };
183 };
184
185 return {
186 state,
187
188 declare(args) {
189 if (!INTENTS.includes(args.intent)) {
190 return { ok: false, text: `nodd_declare: intent must be one of ${INTENTS.join(", ")}` };
191 }
192 if (!ROUTES.includes(args.route)) {
193 return { ok: false, text: `nodd_declare: route must be one of ${ROUTES.join(", ")}` };
194 }
195 if (!args.slug) return { ok: false, text: "nodd_declare: slug is required" };
196
197 // `inline` is small, understood work: it creates no durable artifact
198 // (`routing.go:94`). Only tracked and forge routes get a feature doc.
199 if (args.route === "inline") {
200 return { ok: true, text: `route inline declared for ${args.slug}; no feature document created` };
201 }
202
203 const existing = readDoc(args.slug);
204 const doc = existing ?? emptyDoc({ slug: args.slug, title: args.title || args.slug });
205
206 // "Declared up front" is only a property if it cannot be re-declared
207 // afterwards. Re-declaring rewrote `## Verification` wholesale, so a
208 // refused checkoff was repaired by naming the command that did pass as
209 // the runner -- the round-1 echo attack with one extra step. A runner is
210 // pinned once; changing it means a new feature, or `/nodd-allow`.
211 const requested = args.runner && args.runner !== "" ? args.runner : null;
212
213 // Strict TDD is "a RED run of the declared runner before the GREEN". With
214 // no runner there is no such run to require, and the gate silently
215 // skipped the check while the document still read `- tdd: strict`. A doc
216 // asserting a discipline nothing enforces is the exact failure NODD
217 // exists to prevent, so the declaration is refused instead.
218 if (args.tdd === "strict" && (requested ?? doc.verification.runner) === null) {
219 return {
220 ok: false,
221 text: "nodd_declare: tdd: strict requires a runner, because a RED run is a failing run of the declared runner. Declare one, or declare tdd: off.",
222 };
223 }
224
225 // The runner is pinned once; the discipline it is run under has to be
226 // pinned the same way. Omitting `tdd` on a re-declaration rewrote the doc
227 // to `- tdd: off` silently, and a GREEN with no RED then checked the task
228 // off -- the re-pinning attack with one word removed instead of one added.
229 if (doc.verification.tdd === "strict" && args.tdd !== "strict") {
230 return {
231 ok: false,
232 text: `nodd_declare: ${args.slug} is pinned to tdd: strict, and dropping the discipline after the work changes what the checkoff means. Re-declare with tdd: strict, or declare a new feature.`,
233 };
234 }
235
236 if (doc.verification.runner !== null && requested !== null && requested !== doc.verification.runner) {
237 return {
238 ok: false,
239 text: `nodd_declare: ${args.slug} already pinned \`${doc.verification.runner}\` as its verification runner, and a runner chosen after the work is a runner chosen to fit it. To verify differently, declare a new feature.`,
240 };
241 }
242
243 doc.objective = args.summary;
244 // `/nodd-promote` reads these three into the forge handoff
245 // (`src/promote.ts:58-60`), where they read "Not recorded in the NODD
246 // run" for every feature ever declared, because nothing could fill
247 // them. Optional: a small inline fix owes no problem statement, and a
248 // document that demands one it cannot get teaches people to ignore it.
249 if (args.problem) doc.problem = args.problem;
250 if (args.scope) doc.scope = args.scope;
251 if (args.constraints) doc.constraints = args.constraints;
252 doc.route = { intent: args.intent, route: args.route };
253 // Declared once and written by the extension, so the runner a checkoff is
254 // measured against is not a string the model can pick per checkoff.
255 doc.verification = {
256 runner: requested ?? doc.verification.runner,
257 tdd: args.tdd === "strict" ? "strict" : "off",
258 source: "nodd_declare",
259 files: [...new Set((args.files ?? []).filter((file) => typeof file === "string" && file !== ""))],
260 };
261 return saveDoc(doc);
262 },
263
264 task(args) {
265 const doc = readDoc(args.slug);
266 if (!doc) {
267 return { ok: false, text: `nodd_task: no feature document for ${args.slug}; call nodd_declare first` };
268 }
269
270 if (args.action === "add") {
271 let id = typeof args.id === "string" && args.id.trim() !== "" ? args.id : "";
272 for (let n = doc.tasks.length + 1; id === ""; n += 1) {
273 if (!doc.tasks.some((t) => t.id === `T${n}`)) id = `T${n}`;
274 }
275 if (doc.tasks.some((t) => t.id === id)) {
276 return { ok: false, text: `nodd_task: ${id} already exists` };
277 }
278 doc.tasks.push({ id, title: args.title ?? id, checked: false });
279 const saved = saveDoc(doc);
280 return saved.ok ? { ok: true, text: `${id} added to .nodd/${doc.slug}/feature.md` } : saved;
281 }
282
283 if (typeof args.id !== "string" || args.id.trim() === "") {
284 return { ok: false, text: `nodd_task: ${args.action} needs the task id, e.g. T1` };
285 }
286
287 if (args.action === "reopen") {
288 const reopened = reopenTask(doc, args.id, args.reason ?? "");
289 if (!reopened.ok) return { ok: false, text: `nodd_task: ${reopened.problem}` };
290 const saved = saveDoc(reopened.doc);
291 return saved.ok ? { ok: true, text: `${args.id} reopened in .nodd/${doc.slug}/feature.md` } : saved;
292 }
293
294 const index = doc.tasks.findIndex((t) => t.id === args.id);
295 if (index < 0) return { ok: false, text: `nodd_task: ${args.id} is not in the document` };
296
297 // A checkoff is gated like any other claim: the evidence gate answers
298 // from observed command results, and what it returns is what gets
299 // written. NODD never invents a command it did not see run.
300 const ledger = ledgerPath(root(), doc.slug);
301 const { records } = readLedger(ledger, fs);
302 const verdict = evidenceGate(
303 state.committed,
304 records,
305 {
306 task: args.id,
307 // The task's own files when it declared some, the whole session's
308 // writes otherwise. Either way a real timestamp, not a bash proxy.
309 ...lastWrite(state.committed, doc.verification.files, doc.verification.runner),
310 runner: doc.verification.runner,
311 ...(doc.verification.tdd === "strict" && doc.verification.runner
312 ? { tdd: { mode: "strict" as const, source: doc.verification.source, runner: doc.verification.runner } }
313 : {}),
314 },
315 policy,
316 );
317 if (!verdict.allow) {
318 const hatch = consumeHatch(policy, "evidence");
319 if (!hatch) return { ok: false, text: verdict.reason };
320 policy = hatch.policy;
321 spendHatch("evidence");
322 const task = doc.tasks[index];
323 doc.tasks[index] = {
324 id: task.id,
325 title: task.title,
326 checked: true,
327 evidence: { command: "none", outcome: `none (override: ${hatch.reason || "no reason given"})` },
328 candidate: candidateIdentity(candidateFor(state.committed)),
329 };
330 const saved = saveDoc(doc);
331 return saved.ok ? { ok: true, text: `${args.id} checked in .nodd/${doc.slug}/feature.md on a one-shot override, with no observed run` } : saved;
332 }
333
334 // Record the evidence this checkoff relied on. Without this call the
335 // ledger never exists, `readLedger` always returns `[]`, and every
336 // integrity rule the README documents is unreachable code.
337 const used = state.committed.commandResults.find((run) => run.toolCallId === verdict.observed.toolCallId);
338 if (used) {
339 try {
340 appendRecord(ledger, {
341 toolCallId: used.toolCallId,
342 tool: "bash",
343 command: used.command,
344 outcome: parseOutcome(used.isError, used.resultText),
345 at: used.at,
346 }, fs);
347 } catch {
348 // A ledger NODD cannot write is a reported limitation, not a reason to
349 // discard a checkoff the gate already allowed on observed evidence.
350 }
351 }
352
353 // The candidate is the observed commit SHA, or `pending-commit` when this
354 // session has seen no commit. Never the checkbox (`routing.go:51`,`:102`).
355 const task = doc.tasks[index];
356 doc.tasks[index] = {
357 id: task.id,
358 title: task.title,
359 checked: true,
360 evidence: { command: verdict.observed.command, outcome: verdict.observed.outcome },
361 candidate: candidateIdentity(candidateFor(state.committed)),
362 };
363 const saved = saveDoc(doc);
364 return saved.ok ? { ok: true, text: `${args.id} checked in .nodd/${doc.slug}/feature.md` } : saved;
365 },
366
367 checkCall(raw) {
368 const request = { ...raw, input: anchorPath(raw.input ?? {}, root()) };
369 // Registry order, first refusal wins: one call never collects two
370 // messages about the same missing declaration.
371 const decisions: Array<[(typeof GATE_IDS)[number], () => GateDecision]> = [
372 ["authorize", () => authorizeGate(state.committed, request, policy, isReadOnlyAgent)],
373 ["classify", () => classifyGate(state.committed, request, policy, state.pending)],
374 ["track", () => trackGate(state.committed, request, policy, (slug) => fs.existsSync(featureDocPath(root(), slug)))],
375 ["delegate", () => delegateGate(state.committed, request, policy, state.pending)],
376 ["promotion", () => promotionGate(promotionSignals(state.committed, request), request, policy)],
377 ];
378
379 for (const [gate, evaluate] of decisions) {
380 const decision = evaluate();
381 if (decision.allow) continue;
382
383 // A one-shot override is spent by the refusal it prevents — never
384 // implicitly, never across gates, never twice.
385 const hatch = consumeHatch(policy, gate);
386 if (hatch) {
387 policy = hatch.policy;
388 spendHatch(gate);
389 return null;
390 }
391 // `terminate` is deliberately never set: a blocked call stops that
392 // call, not the agent.
393 return { block: true, reason: decision.reason };
394 }
395 return null;
396 },
397
398 reloadPolicy() {
399 // Re-read rather than cache: `/nodd-gates off` writes the file, and a
400 // policy only read at startup leaves the gate blocking while disk already
401 // says it is off — a kill switch the user watches fail.
402 //
403 // An explicit `setPolicy` wins: it is the caller stating the whole policy,
404 // and re-reading over it would silently undo what they just set.
405 if (policyIsPinned) return policy;
406 // Everything comes from `loadPolicy`: hatches because `/nodd-allow` runs
407 // in another module and persists them, CLI flags because they are fixed
408 // for the session. Preserving the in-memory copies here would discard an
409 // override the moment it was granted, and drop `--nodd-off` on the first
410 // reload after startup.
411 policy = loadPolicy();
412 return policy;
413 },
414
415 setPolicy(next) {
416 policyIsPinned = true;
417 policy = next;
418 },
419
420 policy() {
421 return policy;
422 },
423
424 onToolCall(event) {
425 state.pending.set(event.toolCallId, pendingCall({
426 toolCallId: event.toolCallId,
427 toolName: event.toolName,
428 input: anchorPath(event.input ?? {}, root()),
429 }));
430 },
431 forgetPending(toolCallId) {
432 state.pending.delete(toolCallId);
433 },
434 forgetAllPending() {
435 state.pending.clear();
436 },
437 onToolResult(event) {
438 state.pending.delete(event.toolCallId);
439 const obs = observation({
440 toolCallId: event.toolCallId,
441 toolName: event.toolName,
442 input: anchorPath(event.input ?? {}, root()),
443 isError: event.isError === true,
444 resultText: resultText(event.content),
445 at: now(),
446 });
447 state.committed = fold(state.committed, obs);
448 return obs;
449 },
450 replay(entries) {
451 for (const entry of entries ?? []) {
452 const isNodd = entry?.customType === OBSERVATION_ENTRY || entry?.type === OBSERVATION_ENTRY;
453 if (!isNodd) continue;
454 const data = entry.data as Observation | undefined;
455 if (!data?.toolCallId) continue;
456
457 state.committed = fold(state.committed, observation(data));
458 // The context is rebuilt; the evidence is not. A command another process
459 // observed is not a command this one observed.
460 state.committed = { ...state.committed, commandResults: [] };
461 }
462 },
463 taskProgress() {
464 // The doc on disk is the source of truth, as everywhere else in NODD:
465 // a counter read from memory could disagree with the file the user sees.
466 const slug = state.committed.declaration?.slug;
467 if (!slug) return null;
468 const doc = readDoc(slug);
469 if (!doc || doc.tasks.length === 0) return null;
470 return { done: doc.tasks.filter((t) => t.checked).length, total: doc.tasks.length };
471 },
472 evidenceView() {
473 return state.committed;
474 },
475 };
476}
477
478export const DECLARE_SCHEMA = {
479 description:
480 "Declare the authorized intent and implementation route for this request. On a tracked or forge route NODD creates .nodd/<slug>/feature.md and reports it in one line.",
481 parameters: {
482 type: "object",
483 properties: {
484 intent: { type: "string", enum: INTENTS, description: "read-only work never writes" },
485 route: { type: "string", enum: ROUTES, description: "inline stays small; tracked and forge create a feature document" },
486 slug: { type: "string", description: "filename-safe feature identity" },
487 summary: { type: "string", description: "the objective, in one or two sentences" },
488 title: { type: "string", description: "human-readable feature title" },
489 problem: { type: "string", description: "what is wrong today; read by /nodd-promote into the forge handoff" },
490 scope: { type: "string", description: "what this work covers and what it deliberately leaves out" },
491 constraints: { type: "string", description: "limits the work must respect: no new dependency, no API change" },
492 runner: {
493 type: "string",
494 description:
495 "the verification command for this feature, e.g. `npm test`. Only observed runs of this command can check a task off; declare it now, because you cannot choose it later.",
496 },
497 tdd: { type: "string", enum: ["strict", "off"], description: "strict requires an observed failing run before a checkoff" },
498 files: {
499 type: "array",
500 items: { type: "string" },
501 description: "the files this work will touch; writing more distinct files than declared escalates to promotion",
502 },
503 },
504 required: ["intent", "route", "slug", "summary"],
505 },
506};
507
508export const TASK_SCHEMA = {
509 description:
510 "Add a task to the feature document, check one off, or reopen a checked one. NODD writes the document; you never edit it directly.",
511 parameters: {
512 type: "object",
513 properties: {
514 action: { type: "string", enum: ["add", "check", "reopen"] },
515 id: { type: "string", description: "stable task id, e.g. T1" },
516 title: { type: "string", description: "required when adding" },
517 slug: { type: "string", description: "the feature slug" },
518 reason: { type: "string", description: "required when reopening: why the completed result no longer holds" },
519 },
520 required: ["action", "id", "slug"],
521 },
522};
523
524export function ledgerPath(cwd: string, slug: string): string {
525 return joinPath(cwd, ".nodd", slug, "ledger.json");
526}
527
528/**
529 * The most recent observed write that evidence must postdate.
530 *
531 * Reads `filesWritten`, which is where writes actually are. Round 1 read
532 * `commandResults` — bash only — so a `write`/`edit` after a green run moved
533 * nothing, and the stale run certified the edit it predated.
534 *
535 * Scoped to the declared files when there are any: a task is certified by a run
536 * postdating *its* edits, not every edit in the session.
537 */
538function lastWrite(committed: Committed, declaredFiles: string[] = [], runner: string | null = null): { lastWriteAt: string; lastWriteSeq: number } {
539 const relevant = declaredFiles.length > 0
540 ? [...committed.filesWritten].filter(([path]) => declaredFiles.includes(path))
541 : [...committed.filesWritten];
542
543 let latest = { at: new Date(0).toISOString(), seq: 0 };
544 for (const [, write] of relevant) {
545 if (write.seq > latest.seq) latest = write;
546 }
547 for (const run of committed.commandResults) {
548 if (run.seq > latest.seq && writesByBash(run.command, runner)) latest = { at: run.at, seq: run.seq };
549 }
550 return { lastWriteAt: latest.at, lastWriteSeq: latest.seq };
551}
552
553/**
554 * What `gate-promotion` may look at, derived from what this kernel observed.
555 *
556 * Round 1 returned zeroes here with a comment saying a fabricated signal is
557 * worse than an absent one — which was right, and was then used to justify
558 * shipping the gate wired to constants, so it could never fire. Both real
559 * signals are derivable from state the kernel already holds:
560 *
561 * - `declaredFiles` from the declaration's own file list;
562 * - `observedFiles` from `filesWritten`;
563 * - `consecutiveFailures` from the trailing non-success runs of the declared
564 * runner, which is the only command whose failures say the plan is wrong.
565 *
566 * The third specified signal, "the user asked", was not derivable and has been
567 * removed from the product rather than faked (see `src/gates/promotion.ts`).
568 */
569function promotionSignals(committed: Committed, request: GateRequest) {
570 const declaration = committed.declaration;
571 const runner = declaration?.runner ?? null;
572
573 // Only runs of the declared runner count, and only the trailing streak: a
574 // failing `grep` is not a failing plan, and a success clears the streak.
575 let consecutiveFailures = 0;
576 if (runner !== null) {
577 for (let i = committed.commandResults.length - 1; i >= 0; i--) {
578 const run = committed.commandResults[i];
579 if (!isDeclaredRunner(run.command, runner) || masksExitStatus(run.command, runner)) continue;
580 if (isSuccess(parseOutcome(run.isError, run.resultText))) break;
581 consecutiveFailures += 1;
582 }
583 }
584
585 // The file this call is about to write counts too. Write intent is known at
586 // preflight, and a gate that only saw finished writes would refuse the
587 // divergence one file late — after the divergent write already happened.
588 const since = declaration?.seq ?? 0;
589 const files = new Set([...committed.filesWritten].filter(([, write]) => write.seq > since).map(([path]) => path));
590 const target = targetPath(request);
591 if (target && isFileWrite(request)) files.add(target);
592 const declared = declaration?.files ?? [];
593 const undeclared = [...files].filter((path) => !isDeclaredPath(path, declared));
594
595 return {
596 slug: declaration?.slug ?? "",
597 consecutiveFailures,
598 failedTaskId: consecutiveFailures > 0 ? runner : null,
599 declaredFiles: declaration?.files.length ?? 0,
600 observedFiles: files.size,
601 undeclared,
602 };
603}
604
605/**
606 * Gate flags as configured. An unreadable config means nobody chose.
607 *
608 * `home` is a parameter, not `homedir()` inside: the tests call `register()`
609 * directly, and reading the real `~/.pi/nodd.json` made the suite's verdict
610 * depend on the machine running it.
611 */
612export function readPolicy(fs: Fs, home: string): Policy {
613 try {
614 const { config } = parseConfig(fs.readFileSync(noddConfigPath(home), "utf8"));
615 return { ...emptyPolicy(), config: config.gates, hatches: readHatches(fs, home) };
616 } catch {
617 return { ...emptyPolicy(), hatches: readHatches(fs, home) };
618 }
619}
620
621/**
622 * Why the config could not be used, if it could not be used.
623 *
624 * A file that fails to parse resets every gate to its default, which quietly
625 * re-enables whatever the user turned off. `parseConfig` already computes the
626 * diagnosis; dropping it left the user with gates they had disabled and no
627 * reason given.
628 */
629export function configComplaint(fs: Fs, home: string): string | null {
630 const path = noddConfigPath(home);
631 if (!fs.existsSync(path)) return null;
632 try {
633 const { defects } = parseConfig(fs.readFileSync(path, "utf8"));
634 if (defects.length === 0) return null;
635 return `nodd: ${defects.join("; ")}. Every gate is at its default until this is fixed (${path}).`;
636 } catch (err) {
637 return `nodd: could not read ${path} (${err instanceof Error ? err.message : String(err)}). Every gate is at its default until this is fixed.`;
638 }
639}
640
641/**
642 * One-shot overrides granted by `/nodd-allow`, which runs in another module.
643 * Disk is the only channel between the two, and it is the same channel the
644 * gate config already uses.
645 */
646export function readHatches(fs: Fs, home: string): Policy["hatches"] {
647 try {
648 const raw = JSON.parse(fs.readFileSync(hatchPath(home), "utf8")) as unknown;
649 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
650 return raw as Policy["hatches"];
651 } catch {
652 return {};
653 }
654}
655
656/** Spend a hatch on disk, so one grant can never excuse two refusals. */
657export function clearHatch(fs: Fs, gate: string, home: string): void {
658 try {
659 const remaining = readHatches(fs, home);
660 delete remaining[gate];
661 fs.writeFileSync(hatchPath(home), `${JSON.stringify(remaining)}\n`, "utf8");
662 } catch {
663 // Best effort; the in-memory policy has already dropped it either way.
664 }
665}
666hooks/core/gates/registry.ts 22 lines1// The gate list, in evaluation order. First refusal wins.
2//
3// The order is not cosmetic. `authorize` and `classify` are deliberately
4// disjoint — `authorize` speaks only when an intent *was* declared read-only,
5// `classify` only when nothing was declared — so the same call never collects
6// two messages saying different things about the same missing declaration.
7
8export const GATE_IDS = Object.freeze([
9 "authorize",
10 "classify",
11 "track",
12 "delegate",
13 "evidence",
14 "promotion",
15] as const);
16
17export type GateId = (typeof GATE_IDS)[number];
18
19export function isGateId(value: string): value is GateId {
20 return (GATE_IDS as readonly string[]).includes(value);
21}
22hooks/core/gates/policy.ts 84 lines1// The shape every NODD gate shares: a decision, a flag and an escape hatch.
2//
3// `remedy` is non-optional on a refusal. A gate that blocks without telling the
4// agent how to proceed is the worst kind of gate — it turns enforcement into a
5// dead end — so the type makes that unwritable rather than merely discouraged.
6//
7// Flag resolution keeps its source instead of collapsing it to a boolean,
8// because `/nodd-gates status` has to answer "who decided this?" and `default`
9// has to mean "nobody chose" (`routing.go:114`).
10
11import type { GateId } from "./registry.ts";
12
13export type Remedy = {
14 action: string;
15 escapeHatch: `/nodd-allow ${GateId}`;
16};
17
18export type GateDecision =
19 | { allow: true }
20 | { allow: false; gate: GateId; reason: string; remedy: Remedy };
21
22export type FlagSource = "flag" | "config" | "default";
23export type ResolvedFlag = { enabled: boolean; source: FlagSource };
24
25export type Policy = {
26 /** From `~/.pi/nodd.json`. */
27 config: Record<string, { enabled: boolean }>;
28 /** From the `--nodd-off` CLI flag. `all` disables every gate. */
29 flags: Record<string, boolean>;
30 /** Granted one-shot overrides, by gate id. */
31 hatches: Record<string, { reason: string; at?: string }>;
32};
33
34export function emptyPolicy(): Policy {
35 return { config: {}, flags: {}, hatches: {} };
36}
37
38export function resolveFlag(gate: GateId, policy: Policy): ResolvedFlag {
39 if (policy.flags.all === false) return { enabled: false, source: "flag" };
40 const flag = policy.flags[gate];
41 if (typeof flag === "boolean") return { enabled: flag, source: "flag" };
42 const configured = policy.config[gate]?.enabled;
43 if (typeof configured === "boolean") return { enabled: configured, source: "config" };
44 // NODD's gates default on: enforcement is the product. Everything else about
45 // the kill switch is ODD's, unchanged.
46 return { enabled: true, source: "default" };
47}
48
49export function allow(): GateDecision {
50 return { allow: true };
51}
52
53/**
54 * Build a refusal. The rendered reason always carries three things: what was
55 * observed, the concrete action, and the escape hatch.
56 */
57export function refuse(gate: GateId, observed: string, action: string): GateDecision {
58 const escapeHatch = `/nodd-allow ${gate}` as const;
59 return {
60 allow: false,
61 gate,
62 reason: `nodd/${gate}: ${observed}. To proceed: ${action}. To override this once: ${escapeHatch}`,
63 remedy: { action, escapeHatch },
64 };
65}
66
67
68export function grantHatch(policy: Policy, gate: GateId, reason: string, at?: string): Policy {
69 return { ...policy, hatches: { ...policy.hatches, [gate]: { reason, ...(at ? { at } : {}) } } };
70}
71
72/**
73 * Consume a one-shot override for exactly this gate. Returns the reason and the
74 * policy with the override spent, or null when there is none — never implicit,
75 * never permanent, never cross-gate.
76 */
77export function consumeHatch(policy: Policy, gate: GateId): { reason: string; policy: Policy } | null {
78 const hatch = policy.hatches[gate];
79 if (!hatch) return null;
80 const hatches = { ...policy.hatches };
81 delete hatches[gate];
82 return { reason: hatch.reason, policy: { ...policy, hatches } };
83}
84hooks/core/config-core.ts 116 lines1// NODD's configuration lives in `~/.pi/nodd.json` and nowhere else. Forge's own
2// config file is never read and never written: the two products share a home
3// directory, not a schema. A test asserts no NODD source file even names it.
4//
5// This module is pure: it parses text and merges objects. Reading and writing
6// the file is the extensions' job.
7
8import { joinPath } from "./path.ts";
9import type { ConfigurableSlot } from "./manifest.ts";
10
11export type GateFlag = { enabled: boolean };
12
13export type NoddConfig = {
14 /** slot -> `provider/model` (or a bare model id pi can resolve). */
15 models: Partial<Record<ConfigurableSlot, string>>;
16 /** profile name -> a full slot assignment set. */
17 profiles: Record<string, Partial<Record<ConfigurableSlot, string>>>;
18 activeProfile: string | null;
19 /** gate id -> flag. Absent means "nobody chose"; the default applies. */
20 gates: Record<string, GateFlag>;
21};
22
23export const DEFAULT_CONFIG: NoddConfig = Object.freeze({
24 models: {},
25 profiles: {},
26 activeProfile: null,
27 gates: {},
28});
29
30export function noddConfigPath(home: string): string {
31 return joinPath(home, ".pi", "nodd.json");
32}
33
34/**
35 * Where a one-shot override waits between the command that grants it and the
36 * refusal it prevents.
37 *
38 * Deliberately not `nodd.json`: that file is the user's durable configuration,
39 * and a hatch is spent within seconds. Keeping the two apart means a crash
40 * mid-override cannot corrupt the profiles, and the user never finds ephemeral
41 * state in the file they hand-edit.
42 */
43export function hatchPath(home: string): string {
44 return joinPath(home, ".pi", "nodd-hatch.json");
45}
46
47function asObject(value: unknown): Record<string, unknown> {
48 return typeof value === "object" && value !== null && !Array.isArray(value)
49 ? (value as Record<string, unknown>)
50 : {};
51}
52
53function asStringMap(value: unknown): Record<string, string> {
54 const out: Record<string, string> = {};
55 for (const [key, raw] of Object.entries(asObject(value))) {
56 if (typeof raw === "string") out[key] = raw;
57 }
58 return out;
59}
60
61export type ParsedConfig = { config: NoddConfig; defects: string[] };
62
63/**
64 * Tolerant read. An absent file, an empty file and `{{{` all produce the
65 * documented defaults — a broken config must never take a session down. A parse
66 * failure is reported as a defect so the user can see it; missing sections are
67 * not defects, they are just unset.
68 */
69export function parseConfig(text: string | undefined): ParsedConfig {
70 if (text === undefined || text.trim() === "") {
71 return { config: { ...DEFAULT_CONFIG }, defects: [] };
72 }
73
74 let raw: unknown;
75 try {
76 raw = JSON.parse(text);
77 } catch (err) {
78 const message = err instanceof Error ? err.message : String(err);
79 return { config: { ...DEFAULT_CONFIG }, defects: [`nodd.json is not valid JSON: ${message}`] };
80 }
81
82 const root = asObject(raw);
83 const profiles: NoddConfig["profiles"] = {};
84 for (const [name, value] of Object.entries(asObject(root.profiles))) {
85 profiles[name] = asStringMap(value) as NoddConfig["profiles"][string];
86 }
87
88 const gates: NoddConfig["gates"] = {};
89 for (const [id, value] of Object.entries(asObject(root.gates))) {
90 const enabled = asObject(value).enabled;
91 if (typeof enabled === "boolean") gates[id] = { enabled };
92 }
93
94 return {
95 config: {
96 models: asStringMap(root.models) as NoddConfig["models"],
97 profiles,
98 activeProfile: typeof root.activeProfile === "string" ? root.activeProfile : null,
99 gates,
100 },
101 defects: [],
102 };
103}
104
105/**
106 * Merge a patch into the raw parsed JSON, section by section. Unknown top-level
107 * keys survive untouched: NODD is a guest in a file the user owns, and losing a
108 * key nobody asked us to manage would be a silent data loss.
109 */
110export function mergeConfig(
111 existing: Record<string, unknown>,
112 patch: Record<string, unknown>,
113): Record<string, unknown> {
114 return { ...existing, ...patch };
115}
116hooks/core/path.ts 37 lines1function normalize(path: string): string {
2 const absolute = path.startsWith("/");
3 const out: string[] = [];
4 for (const segment of path.split("/")) {
5 if (segment === "" || segment === ".") continue;
6 if (segment === "..") {
7 if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
8 else if (!absolute) out.push("..");
9 continue;
10 }
11 out.push(segment);
12 }
13 const body = out.join("/");
14 if (absolute) return `/${body}`;
15 return body === "" ? "." : body;
16}
17
18export function joinPath(...parts: string[]): string {
19 const joined = parts.filter((part) => part !== "").join("/");
20 return joined === "" ? "." : normalize(joined);
21}
22
23export function dirnamePath(path: string): string {
24 const trimmed = path.length > 1 ? path.replace(/[\\/]+$/, "") : path;
25 const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
26 if (cut < 0) return ".";
27 if (cut === 0) return trimmed.slice(0, 1);
28 return trimmed.slice(0, cut);
29}
30
31export function extnamePath(path: string): string {
32 const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
33 const base = path.slice(cut + 1);
34 const dot = base.lastIndexOf(".");
35 return dot <= 0 ? "" : base.slice(dot);
36}
37hooks/core/close-report.ts 217 lines1import type { Committed } from "./state.ts";
2import { isDeclaredRunner, masksExitStatus, splitCd, writesByBash } from "./gates/evidence.ts";
3import { describeOutcome, isSuccess, parseOutcome } from "./outcome.ts";
4import { isDeclaredPath } from "./gates/request.ts";
5import { testCounts } from "./test-counts.ts";
6import { bashChangesCode } from "./code-targets.ts";
7
8export type TestEdit = { path: string; seq: number; addedMarkers: string[]; droppedAssertions: number };
9
10export type CloseInput = {
11 committed: Committed;
12 since: number;
13 slug: string | null;
14 runner: string | null;
15 declaredFiles: string[];
16 answer: string;
17 testEdits: TestEdit[];
18 home?: string;
19};
20
21export type CloseReport = { line: string; warnings: string[]; verified: boolean };
22
23const MARKER = /\.(skip|only|todo)\(|\bx(it|describe|test)\(|\bf(it|describe)\(|\bt\.Skip(Now|f)?\(|@pytest\.mark\.(skip|xfail)|@unittest\.skip/g;
24const ASSERTION = /\bexpect\s*\(|\bassert\w*\s*[(.\s]|\bt\.(Error|Fatal)f?\s*\(|\brequire\.\w+\s*\(/g;
25const CLAIM_TESTS = /\b(pasan (los |todos los )?(\d+ )?tests?|(los |todos los |\d+ )?tests (pasan|pasaron|pass(es|ed)?|passing|en verde|verdes)|suite (en )?verde|all (the )?tests pass(ed)?|all green|veredicto:\s*pasa)\b/gi;
26const CLAIM_DONE = /(^|\n)\s*[*#>-]*\s*(listo|lista|hecho|done|fixed)\b(?!\s+(de|para|los|las|el|la|que)\b)|\b(está|quedó|quedaron|queda|quedan)\s+(listo|lista|listos|listas|arreglad[oa]s?|resuelt[oa]s?|terminad[oa]s?|funcionando)\b|\b(it works|now works|is fixed|is done)\b/gi;
27const NEGATED = /\b(no|not|ni|sin|nunca|never)\b[^.!?\n]{0,20}$/i;
28const TEST_RUN = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(node|deno|bun)\s+(--)?test\b|\b(pytest|vitest|jest|rspec|phpunit|tox|nox)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmvn\s+\S*\s*test\b|\bgradlew?\s+\S*test\b|\bmake\s+test\b|\bclaude\s+plugin\s+test\b|\bgh\s+pr\s+checks\b|\bgh\s+run\s+watch\b[^\n]*--exit-status|[\w./-]*_test\.sh\b|\btest_[\w-]*\.py\b/;
29const ARTIFACT = /(^|\/)(\.next|dist|build|coverage|node_modules|target|__pycache__|\.cache|\.turbo|out)(\/|$)/;
30const WRAPPER = /^(?:(?:timeout\s+(?:-\S+\s+)*\d+[smh]?|nice(?:\s+-n\s*-?\d+)?|env(?:\s+[A-Za-z_]\w*=\S*)*)\s+)+/;
31const MISSING = /Missing script:|command not found|No such file or directory|ModuleNotFoundError|Cannot find module/;
32const STRING = /(["'`])(?:\\.|(?!\1)[^\\\n])*\1/g;
33const AGENT_OWN = /^\/tmp\/claude-\d+\/|\/\.claude[^/]*\/(projects|plans|todos|tasks|shell-snapshots)\//;
34const HARMLESS = (path: string) => AGENT_OWN.test(path) || ARTIFACT.test(path) || /\.(md|mdx|txt|rst|adoc|log|png|jpe?g|gif|svg|webp)$/i.test(path);
35
36function withoutData(command: string): string {
37 const lines = command.split("\n");
38 const kept: string[] = [];
39 for (let i = 0; i < lines.length; i += 1) {
40 kept.push(lines[i]);
41 const opener = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/.exec(lines[i]);
42 if (!opener) continue;
43 const end = lines.findIndex((line, at) => at > i && line.trim() === opener[2]);
44 if (end === -1) break;
45 i = end;
46 }
47 return kept.join("\n").replace(STRING, (match) => `"${match.startsWith('"') ? (match.match(/\$\([^)]*\)/g) ?? []).join(" ") : ""}"`);
48}
49
50function isTestRun(command: string): boolean {
51 return TEST_RUN.test(splitCd(withoutData(command)).rest);
52}
53
54type RunnerView = { command: string; runner: string };
55
56function runnerView(command: string, runner: string): RunnerView | null {
57 if (isDeclaredRunner(command, runner)) return { command, runner };
58 const segments = splitCd(runner).rest.split("&&").map((segment) => segment.trim()).filter(Boolean);
59 const tail = segments[segments.length - 1];
60 const parts = command.split(/&&|;|\n/);
61 for (let i = 0; i < parts.length; i += 1) {
62 const rest = parts.slice(i).join("&&").trim().replace(WRAPPER, "");
63 if (isDeclaredRunner(rest, tail)) return { command: rest, runner: tail };
64 }
65 return null;
66}
67
68function claimIn(text: string, pattern: RegExp): string | undefined {
69 for (const match of text.matchAll(pattern)) {
70 if (!NEGATED.test(text.slice(Math.max(0, match.index - 30), match.index))) return match[0];
71 }
72 return undefined;
73}
74
75export function isTestFile(path: string): boolean {
76 return /(^|\/)(tests?|__tests__|spec)\//.test(path)
77 || /[._](test|spec)\.[cm]?[jt]sx?$/.test(path)
78 || /_test\.(go|py)$/.test(path)
79 || /(^|\/)test_[^/]+\.py$/.test(path);
80}
81
82function counts(text: string, pattern: RegExp): Map<string, number> {
83 const found = new Map<string, number>();
84 for (const match of text.matchAll(pattern)) found.set(match[0], (found.get(match[0]) ?? 0) + 1);
85 return found;
86}
87
88function total(found: Map<string, number>): number {
89 return [...found.values()].reduce((sum, count) => sum + count, 0);
90}
91
92export function testEditSignals(oldText: string, newText: string): { addedMarkers: string[]; droppedAssertions: number } {
93 const before = counts(oldText.replace(STRING, '""'), MARKER);
94 const after = counts(newText.replace(STRING, '""'), MARKER);
95 const addedMarkers = [...after].filter(([marker, count]) => count > (before.get(marker) ?? 0)).map(([marker]) => marker);
96 const droppedAssertions = Math.max(0, total(counts(oldText, ASSERTION)) - total(counts(newText, ASSERTION)));
97 return { addedMarkers, droppedAssertions };
98}
99
100function isPartial(command: string, runner: string): boolean {
101 let rest = splitCd(command).rest;
102 for (const token of splitCd(runner).rest.trim().split(/\s+/)) rest = rest.trimStart().slice(token.length);
103 const own = rest.split(/&&|\|\||[;|)]/)[0];
104 return own.replace(/\s*\d*&?>>?&?\s*\S+/g, "").trim() !== "";
105}
106
107type Verdict = "green" | "red" | null;
108
109function hiddenVerdict(command: string, output: string): Verdict {
110 const echo = /echo\s+["']?([^"'$\n]*)\$\?/.exec(command);
111 if (echo) {
112 const prefix = echo[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113 const code = new RegExp(`${prefix}\\s*(\\d+)`).exec(output)?.[1];
114 if (code !== undefined) return code === "0" ? "green" : "red";
115 }
116 if (/^(--- FAIL|FAIL\s)/m.test(output)) return "red";
117 if (/^ok\s+\S+/m.test(output)) return "green";
118 const count = testCounts(output);
119 if (!count) return null;
120 if (count.failed > 0) return "red";
121 return count.passed > 0 ? "green" : null;
122}
123
124function plural(count: number, word: string): string {
125 return `${count} ${word}${count === 1 ? "" : "s"}`;
126}
127
128export function closeReport(input: CloseInput): CloseReport | null {
129 const { committed, since, runner } = input;
130 const written = [...committed.filesWritten].filter(([, write]) => write.seq > since);
131 const runs = committed.commandResults.filter((run) => run.seq > since);
132 const bashWrites = runs.filter((run) => writesByBash(run.command, runner) && !isTestRun(run.command));
133 if (written.length === 0 && bashWrites.length === 0) return null;
134
135 const codeWritten = written.filter(([path]) => !HARMLESS(path));
136 const codeBash = bashWrites.filter((run) => bashChangesCode(run.command, HARMLESS, input.home));
137 const codeChanged = codeWritten.length > 0 || codeBash.length > 0;
138 const lastWrite = codeChanged ? Math.max(...codeWritten.map(([, write]) => write.seq), ...codeBash.map((run) => run.seq)) : since;
139 const views = new Map(runs.map((run) => [run, runner === null ? null : runnerView(run.command, runner)]));
140 const runnerRuns = runner === null ? [] : runs.filter((run) => views.get(run) !== null);
141 const view = (run: (typeof runs)[number]) => views.get(run)!;
142 const after = runnerRuns.filter((run) => run.seq > lastWrite);
143 const trusted = after.filter((run) => !masksExitStatus(view(run).command, view(run).runner));
144 const last = trusted[trusted.length - 1];
145 const lastOutcome = last ? parseOutcome(last.isError, last.resultText) : null;
146 const masked = (run: (typeof runs)[number]) => {
147 const rest = splitCd(run.command).rest;
148 return masksExitStatus(rest, rest.split(/\s+/)[0]);
149 };
150 const verdictOf = (run: (typeof runs)[number]): Verdict => MISSING.test(run.resultText) ? null : masked(run) ? hiddenVerdict(run.command, run.resultText) : isSuccess(parseOutcome(run.isError, run.resultText)) ? "green" : "red";
151 const testRuns = runner !== null ? [] : runs.filter((run) => run.seq > lastWrite && isTestRun(run.command));
152 const testRun = [...testRuns].reverse().find((run) => verdictOf(run) !== null) ?? testRuns[testRuns.length - 1];
153 const testRunMasked = testRun !== undefined && masked(testRun);
154 const testRunGreen = testRun !== undefined && verdictOf(testRun) === "green";
155 const hiddenLast = runner !== null && after.length > 0 && after[after.length - 1] !== last ? after[after.length - 1] : undefined;
156 const hiddenGreen = hiddenLast !== undefined && hiddenVerdict(hiddenLast.command, hiddenLast.resultText) === "green" && !isPartial(view(hiddenLast).command, view(hiddenLast).runner);
157 const verified = runner === null ? testRunGreen : hiddenGreen || (hiddenLast === undefined && lastOutcome !== null && isSuccess(lastOutcome) && !isPartial(view(last).command, view(last).runner));
158
159 let status: string;
160 if (!codeChanged) status = "sólo docs o archivos del agente: nada que verificar";
161 else if (runner === null && testRunGreen) status = `✓ \`${testRun!.command}\` verde${testRunMasked ? " según el resumen (el exit status quedó escondido)" : ""} (sin runner declarado)`;
162 else if (hiddenGreen) status = `✓ \`${hiddenLast!.command}\` verde según el resumen (el exit status quedó escondido)`;
163 else if (runner === null && testRun && testRunMasked) status = `\`${testRun.command}\` escondió su exit status y su salida no confirma un verde (sin runner declarado)`;
164 else if (runner === null && testRun) status = `✗ \`${testRun.command}\` → ${describeOutcome(parseOutcome(testRun.isError, testRun.resultText))} (sin runner declarado)`;
165 else if (runner === null) status = "sin runner declarado: NODD no puede decir si esto quedó verificado";
166 else if (hiddenLast || (!last && after.length > 0)) status = `\`${after[after.length - 1].command}\` escondió su exit status y su salida no confirma un verde`;
167 else if (!last) status = `\`${runner}\` sin correr después de la última escritura`;
168 else if (!isSuccess(lastOutcome!)) status = `✗ \`${last.command}\` → ${describeOutcome(lastOutcome!)}`;
169 else if (isPartial(view(last).command, view(last).runner)) status = `verde parcial: \`${last.command}\`, no la suite declarada (\`${runner}\`)`;
170 else {
171 const count = testCounts(last.resultText);
172 status = `✓ \`${last.command}\` verde${count ? ` (${count.passed} tests)` : ""} después de la última escritura`;
173 }
174
175 const warnings: string[] = [];
176 const testClaim = codeChanged ? claimIn(input.answer, CLAIM_TESTS) : undefined;
177 const claim = codeChanged ? testClaim ?? claimIn(input.answer, CLAIM_DONE) : undefined;
178 if (runner !== null && claim && !verified) {
179 warnings.push(`la respuesta afirma «${claim}», pero NODD no vio \`${runner}\` completo y verde después de la última escritura`);
180 } else if (runner === null && testClaim && !verified) {
181 warnings.push(`la respuesta afirma «${testClaim}», pero no se vio ninguna corrida de tests verde después de la última escritura`);
182 }
183
184 if (verified && runner !== null && !hiddenGreen) {
185 const full = runnerRuns
186 .filter((run) => !masksExitStatus(view(run).command, view(run).runner) && !isPartial(view(run).command, view(run).runner))
187 .map((run) => ({ run, count: testCounts(run.resultText) }))
188 .filter((entry): entry is { run: typeof entry.run; count: NonNullable<typeof entry.count> } => entry.count !== null);
189 const current = full.find((entry) => entry.run === last);
190 const earlier = full.filter((entry) => entry.run.seq < last.seq);
191 if (current && earlier.length > 0) {
192 const most = Math.max(...earlier.filter((entry) => isSuccess(parseOutcome(entry.run.isError, entry.run.resultText))).map((entry) => entry.count.passed), 0);
193 if (current.count.passed < most) warnings.push(`la última corrida verde pasó ${current.count.passed} tests; antes, en este pedido, pasaban ${most}`);
194 const first = earlier[0].count.skipped;
195 if (current.count.skipped > first) warnings.push(`los tests salteados subieron de ${first} a ${current.count.skipped}`);
196 }
197 }
198
199 if (input.declaredFiles.length > 0) {
200 const outside = written
201 .map(([path]) => path)
202 .filter((path) => !isDeclaredPath(path, input.declaredFiles) && !AGENT_OWN.test(path));
203 if (outside.length > 0) warnings.push(`${plural(outside.length, "archivo")} fuera de lo declarado: ${outside.join(", ")}`);
204 }
205
206 for (const edit of input.testEdits.filter((e) => e.seq > since)) {
207 const redBefore = runnerRuns.some((run) => run.seq < edit.seq && !isSuccess(parseOutcome(run.isError, run.resultText)));
208 const greenAfter = runnerRuns.some((run) => run.seq > edit.seq && isSuccess(parseOutcome(run.isError, run.resultText)));
209 if (redBefore && greenAfter) warnings.push(`se editó ${edit.path} después de que el runner fallara y antes del verde: revisá que el arreglo esté en el código y no en el test`);
210 if (edit.addedMarkers.length > 0) warnings.push(`${edit.path}: se agregó ${edit.addedMarkers.join(", ")}`);
211 if (edit.droppedAssertions > 0) warnings.push(`${edit.path}: se sacaron ${edit.droppedAssertions} assertions`);
212 }
213
214 const files = plural(written.length, "archivo") + (bashWrites.length > 0 ? " + escrituras por bash" : "");
215 return { line: `◆ NODD · ${input.slug ?? "sin declarar"} · ${files} · ${status}`, warnings, verified };
216}
217hooks/core/state.ts 194 lines1// The read-only kernel: `Observation[]` folded into committed state.
2//
3// Total, deterministic and idempotent by `toolCallId` — replaying a session on
4// reload must land on the same state, so a re-delivered event may never
5// double-count a file or a tool call. Nothing here reads the filesystem and
6// nothing imports pi: every rule downstream of this reducer is testable without
7// a runtime.
8//
9// Bash commands are recorded raw. Whether one mutates the filesystem is the
10// classifier's judgement (`src/bash-classifier.ts`) and is derived when a gate
11// asks, never baked into the record.
12
13import type { Observation, PendingCall } from "./observations.ts";
14import { ROUTES, type Intent, type Route } from "./feature-doc.ts";
15
16export type CommandResult = {
17 toolCallId: string;
18 command: string;
19 isError: boolean;
20 resultText: string;
21 at: string;
22 /**
23 * Position in this session's observation order. `at` is a wall clock and two
24 * tool results can share a millisecond, so "did the run come after the edit?"
25 * is answered by the order the kernel observed them in — which it knows
26 * exactly — rather than by a timestamp's resolution. A rule that is only sound
27 * when the clock happens to tick between two events is not a mechanism.
28 */
29 seq: number;
30};
31
32/** When a file was written, and where that write sits in observation order. */
33export type WriteRecord = { at: string; seq: number };
34
35export type Declaration = {
36 intent: Intent;
37 route: Route;
38 slug: string;
39 /**
40 * Where in observation order this declaration was made. Writes before it
41 * belong to whatever task came earlier: a session that finishes one feature
42 * and declares the next must not carry the first one's file count into it.
43 */
44 seq: number;
45 /**
46 * The verification command this feature is checked with, as declared. Evidence
47 * must come from it: without this, any exit-0 string the model chose certifies
48 * any task, and `echo 'tests pass'` is a valid receipt.
49 */
50 runner: string | null;
51 tdd: "strict" | "off";
52 /** Distinct files the declaration promised to touch. `gate-promotion` reads it. */
53 files: string[];
54};
55
56export type Committed = {
57 seen: Set<string>;
58 filesRead: Set<string>;
59 /**
60 * path -> the most recent write to it. A `Set` made "the evidence ran after
61 * the edit" structurally uncomputable, which is how a green run predating an
62 * edit certified that edit.
63 */
64 filesWritten: Map<string, WriteRecord>;
65 commandResults: CommandResult[];
66 delegations: number;
67 toolCalls: number;
68 declaration: Declaration | null;
69};
70
71/**
72 * The whole session state. `committed` is advanced only by `tool_result`;
73 * `pending` holds calls preflighted in the current assistant batch. pi does not
74 * guarantee a `tool_call` handler sees its siblings' results
75 * (`extensions.md:757-758`), so the two halves are different types on purpose:
76 * evidence readers take `Committed` and therefore cannot reach a pending call.
77 */
78export type NoddState = {
79 committed: Committed;
80 pending: Map<string, PendingCall>;
81};
82
83export function emptyState(): NoddState {
84 return { committed: emptyCommitted(), pending: new Map() };
85}
86
87export function emptyCommitted(): Committed {
88 return {
89 seen: new Set(),
90 filesRead: new Set(),
91 filesWritten: new Map(),
92 commandResults: [],
93 delegations: 0,
94 toolCalls: 0,
95 declaration: null,
96 };
97}
98
99const INTENTS: readonly Intent[] = ["read-only", "change"];
100const READ_TOOLS = new Set(["read"]);
101const WRITE_TOOLS = new Set(["write", "edit"]);
102
103function str(value: unknown): string | null {
104 return typeof value === "string" && value !== "" ? value : null;
105}
106
107/** Distinct declared paths. Counted the same way written files are. */
108function declaredFiles(value: unknown): string[] {
109 if (!Array.isArray(value)) return [];
110 const paths = value.filter((entry): entry is string => typeof entry === "string" && entry !== "");
111 return [...new Set(paths)];
112}
113
114export function fold(committed: Committed, obs: Observation): Committed {
115 if (committed.seen.has(obs.toolCallId)) return committed;
116
117 const next: Committed = {
118 seen: new Set(committed.seen).add(obs.toolCallId),
119 filesRead: new Set(committed.filesRead),
120 filesWritten: new Map(committed.filesWritten),
121 commandResults: [...committed.commandResults],
122 delegations: committed.delegations,
123 toolCalls: committed.toolCalls + 1,
124 declaration: committed.declaration,
125 };
126
127 // `file_path ?? path`: pi's write tool sends the first, edit accepts both
128 // (`write.js:99`, `edit.js:92`). Reading only `path` left filesWritten empty
129 // on every real write, which is what evidence and delegate count.
130 //
131 // `toolCalls` above counts every *executed* call, failed or not: `fold` runs
132 // on `tool_result`, and a gate-blocked call never produces one, so refusals
133 // are invisible here. That is a real limit, not a design: the long-session
134 // backstop in `delegate.ts` never sees a refused attempt. It is left that
135 // way because the alternative — counting refusals — is what made defect #6
136 // self-amplifying, and a session must not be locked out by a number it has
137 // no way to lower.
138 //
139 // `filesRead`/`filesWritten`/`delegations` below model observed outcomes —
140 // what the workspace and this session actually gained — so a failed call
141 // must not advance them, the same rule `commandResults` already applies via
142 // `isError`.
143 const path = str(obs.input.file_path) || str(obs.input.path);
144 if (!obs.isError) {
145 if (READ_TOOLS.has(obs.toolName) && path) next.filesRead.add(path);
146 if (WRITE_TOOLS.has(obs.toolName) && path) next.filesWritten.set(path, { at: obs.at, seq: next.toolCalls });
147 }
148
149 if (obs.toolName === "bash") {
150 const command = str(obs.input.command);
151 if (command) {
152 next.commandResults.push({
153 toolCallId: obs.toolCallId,
154 command,
155 isError: obs.isError,
156 resultText: obs.resultText,
157 at: obs.at,
158 seq: next.toolCalls,
159 });
160 }
161 }
162
163 if (obs.toolName === "subagent" && !obs.isError) next.delegations += 1;
164
165 if (obs.toolName === "nodd_declare" && !obs.isError) {
166 const intent = str(obs.input.intent);
167 const route = str(obs.input.route);
168 const slug = str(obs.input.slug);
169 if (intent && route && slug && INTENTS.includes(intent as Intent) && ROUTES.includes(route as Route)) {
170 next.declaration = {
171 intent: intent as Intent,
172 route: route as Route,
173 slug,
174 // The boundary moves only when the feature changes. Re-declaring the
175 // same slug — which a blocked actor can do for free — would otherwise
176 // push the boundary past the files it just wrote and clear the
177 // writer count, turning `gate-delegate` into one call to skip.
178 seq: next.declaration?.slug === slug ? next.declaration.seq : next.toolCalls,
179 runner: str(obs.input.runner),
180 // Anything but the literal `strict` is off. A mode NODD cannot read is
181 // not a mode it gets to assume.
182 tdd: obs.input.tdd === "strict" ? "strict" : "off",
183 files: declaredFiles(obs.input.files),
184 };
185 }
186 }
187
188 return next;
189}
190
191export function foldAll(committed: Committed, observations: Observation[]): Committed {
192 return observations.reduce(fold, committed);
193}
194hooks/core/destructive.ts 47 lines1function blankQuotes(command: string): string {
2 return command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
3}
4
5function segments(text: string): string[] {
6 return text.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim().replace(/^sudo\s+/, ""));
7}
8
9function outsideRoots(path: string, roots: readonly string[], home: string): boolean {
10 if (!/^(\/|~|\$HOME)/.test(path)) return false;
11 const expanded = home === "" ? path : path.replace(/^(~|\$HOME)(?=\/|$)/, home);
12 if (/^(~|\$HOME)/.test(expanded)) return true;
13 const clean = expanded.replace(/\/+$/, "");
14 return !roots.some((root) => clean === root || clean.startsWith(`${root.replace(/\/+$/, "")}/`));
15}
16
17function rmOutside(segment: string, roots: readonly string[], home: string): string | null {
18 const words = segment.split(/\s+/);
19 if (words[0] !== "rm") return null;
20 const flags = words.slice(1).filter((word) => word.startsWith("-")).join("");
21 if (!/r/i.test(flags) || !/f/.test(flags)) return null;
22 return words.slice(1).find((word) => !word.startsWith("-") && outsideRoots(word, roots, home)) ?? null;
23}
24
25const SQL_CLIENT = /^\s*(sudo\s+)?(\w+=\S*\s+)*(psql|mysql|mariadb|sqlite3|clickhouse-client)\b/;
26const LOCAL_HOST = /(-h\s*|--host[= ])(127\.0\.0\.1|localhost)\b/;
27
28export function destructiveReason(command: string, roots: readonly string[], home = ""): string | null {
29 const syntax = blankQuotes(command ?? "");
30 for (const segment of segments(syntax)) {
31 if (/^git\s+reset\b.*--hard\b/.test(segment)) return "`git reset --hard` discards uncommitted work and moves the branch; `git reset --keep <ref>` moves it the same way but refuses instead of discarding";
32 if (/^git\s+clean\b.*\s-[a-zA-Z]*f/.test(segment)) return "`git clean -f` deletes untracked files for good; `git clean -n` lists them first";
33 if (/^git\s+checkout\s+(--\s+)?\.(\s|$)/.test(segment)) return "`git checkout -- .` throws away every uncommitted change; `git stash` keeps them recoverable";
34 if (/^git\s+restore\b/.test(segment) && !/--staged\b/.test(segment) && /\s(--\s+)?\.(\s|$)/.test(segment)) return "`git restore .` throws away every uncommitted change; `git stash` keeps them recoverable";
35 if (/^terraform\s+destroy\b/.test(segment)) return "`terraform destroy` tears down real infrastructure";
36 if (/^kubectl\s+delete\b/.test(segment)) return "`kubectl delete` removes cluster resources";
37 const outside = rmOutside(segment, roots, home);
38 if (outside) return `\`rm -rf ${outside}\` deletes outside the project`;
39 }
40 for (const line of (command ?? "").split(/\n|&&/)) {
41 if (SQL_CLIENT.test(line) && !LOCAL_HOST.test(line) && /\b(drop\s+(table|database|schema)|truncate)\b/i.test(line)) {
42 return "a DROP or TRUNCATE sent to a database that is not local";
43 }
44 }
45 return null;
46}
47hooks/core/scratch.ts 64 lines1import { classifyBash } from "./bash-classifier.ts";
2
3const ALL_ARGS = new Set(["mkdir", "touch", "rm", "rmdir", "tee", "chmod", "chown", "truncate"]);
4const LAST_ARG = new Set(["cp", "install", "ln"]);
5const REDIRECT = /(?<![<>])(?:\d|&)?>>?(?!&)\s*("[^"]*"|'[^']*'|[^\s;&|<>]+)/g;
6const SINK = /^\/dev\/(null|stdout|stderr)$/;
7
8function withoutHeredocBodies(command: string): string {
9 const lines = command.split("\n");
10 const kept: string[] = [];
11 for (let i = 0; i < lines.length; i += 1) {
12 kept.push(lines[i]);
13 const opener = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/.exec(lines[i]);
14 if (!opener) continue;
15 const end = lines.findIndex((line, at) => at > i && line.trim() === opener[2]);
16 if (end === -1) return command;
17 i = end;
18 }
19 return kept.join("\n");
20}
21
22function unquote(word: string): string {
23 return word.replace(/^(["'])(.*)\1$/, "$2");
24}
25
26function words(segment: string): string[] {
27 return (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(unquote);
28}
29
30export function writesOnlyUnder(command: string, isInside: (path: string) => boolean): boolean {
31 if (classifyBash(command ?? "") !== "mutating") return false;
32 const inside = (path: string) => !/(^|\/)\.\.(\/|$)/.test(path) && isInside(path);
33 const vars = new Map<string, string>();
34 const expand = (text: string) => text.replace(/\$\{(\w+)\}|\$(\w+)/g, (match, a, b) => vars.get(a ?? b) ?? match);
35 let wrote = false;
36 for (const raw of withoutHeredocBodies(command ?? "").split(/&&|\|\||[;\n]|\|/)) {
37 const segment = raw.trim().replace(/^sudo\s+/, "");
38 if (segment === "") continue;
39 const assignment = /^(?:export\s+)?([A-Za-z_]\w*)=("[^"]*"|'[^']*'|\S*)$/.exec(segment);
40 if (assignment) {
41 const value = expand(unquote(assignment[2]));
42 if (inside(value)) vars.set(assignment[1], value);
43 else vars.delete(assignment[1]);
44 continue;
45 }
46 if (classifyBash(segment) !== "mutating") continue;
47 wrote = true;
48 const resolved = expand(segment);
49 const targets = [...resolved.matchAll(REDIRECT)].map((match) => unquote(match[1])).filter((path) => !SINK.test(path));
50 if (!targets.every(inside)) return false;
51 const rest = resolved.replace(REDIRECT, " ");
52 const [head = "", ...args] = words(rest);
53 const paths = args.filter((arg) => !arg.startsWith("-"));
54 if (ALL_ARGS.has(head)) {
55 if (paths.length === 0 || !paths.every(inside)) return false;
56 } else if (LAST_ARG.has(head)) {
57 if (paths.length === 0 || !inside(paths[paths.length - 1])) return false;
58 } else if (classifyBash(rest) === "mutating" || targets.length === 0) {
59 return false;
60 }
61 }
62 return wrote;
63}
64hooks/cc.ts 280 lines1import type { Fs } from './core/io-core.ts'
2import { GATE_IDS, type GateId } from './core/gates/registry.ts'
3import { resolveFlag, type Policy } from './core/gates/policy.ts'
4import type { Committed } from './core/state.ts'
5
6export const VERSION = '0.1.0'
7export const MAIN = 'main'
8export const ENFORCED: readonly GateId[] = ['authorize', 'classify', 'track', 'evidence']
9export const CC_FLAGS: Record<string, boolean> = Object.fromEntries(
10 GATE_IDS.filter((id) => !ENFORCED.includes(id)).map((id) => [id, false]),
11)
12
13const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
14const RENAMED: Record<string, string> = {
15 Write: 'write',
16 Edit: 'edit',
17 MultiEdit: 'edit',
18 NotebookEdit: 'edit',
19 Bash: 'bash',
20 Read: 'read',
21 Glob: 'find',
22 Grep: 'grep',
23 LS: 'ls',
24 Agent: 'subagent',
25 Task: 'subagent',
26}
27
28export type NoddNames = { declare: string; task: string }
29export type Call = { toolName: string; input: Record<string, unknown> }
30
31export function normalizeCall(tool: string, envelope: Record<string, unknown>, names: NoddNames): Call {
32 const input: Record<string, unknown> = {}
33 for (const [key, value] of Object.entries(envelope)) if (!RESERVED.has(key)) input[key] = value
34 if (tool === names.declare) return { toolName: 'nodd_declare', input }
35 if (tool === names.task) return { toolName: 'nodd_task', input }
36 if (tool === 'NotebookEdit' && typeof input.notebook_path === 'string') input.file_path = input.notebook_path
37 if ((tool === 'Agent' || tool === 'Task') && typeof input.subagent_type === 'string') input.agent = input.subagent_type
38 return { toolName: RENAMED[tool] ?? tool, input }
39}
40
41export function isForgeType(type: string | undefined): boolean {
42 return typeof type === 'string' && type.startsWith('forge:')
43}
44
45
46export function gateOfReason(reason: string): string {
47 return /^nodd\/([a-z]+):/.exec(reason)?.[1] ?? 'unknown'
48}
49
50export function ccReason(reason: string): string {
51 return reason.replace(/\/nodd-allow /g, '/nodd allow ')
52}
53
54const REQUEST_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
55
56export function isNewRequest(e: { origin?: { kind?: string }; turnId?: string } | undefined): boolean {
57 return REQUEST_ORIGINS.has(String(e?.origin?.kind)) && !e?.turnId
58}
59
60export const PROTOCOL = [
61 'NODD is on in this session: the ODD protocol is enforced on your tool calls, not left to trust.',
62 'For every user request:',
63 '1. Authorize: a question, review or explanation is read-only. Do not write; if you declare, declare intent read-only.',
64 '2. Explore in proportion to the request, and resolve uncertainty with at most one question before writing.',
65 '3. Classify before the first write: call nodd_declare with intent, route, slug and summary.',
66 ' inline = small, one-step work: send only intent, route, slug and summary. tracked = two or more steps, or progress worth resuming: it creates .nodd/<slug>/feature.md.',
67 ' forge = needs a spec-driven run. Name the runner (the command that verifies the work) when there is one.',
68 '4. Track: on tracked or forge, list the tasks with nodd_task. NODD writes .nodd/; never edit it yourself.',
69 '5. Implement task by task. Delegate wide exploration and independent pieces to subagents.',
70 '6. Close: the outcome, the checks you ran and what they returned, what failed, and the next step.',
71 'Each new user request clears the previous declaration: declare again before its first write. To continue the previous request\'s feature, nodd_declare with only its slug is enough.',
72 'Your session scratchpad is yours: writing there needs no declaration.',
73 'A refused call names its remedy. Only the user can grant a one-shot override, with /nodd allow <gate>.',
74].join('\n')
75
76export const REMINDER = 'NODD: new request. nodd_declare before its first write (only slug to continue).'
77
78export const BUILTIN_AGENTS: Record<string, boolean> = {
79 explore: true,
80 plan: true,
81 'claude-code-guide': true,
82 'statusline-setup': false,
83 'general-purpose': false,
84}
85
86const WRITERS = new Set(['write', 'edit', 'multiedit', 'notebookedit'])
87
88function toolList(frontmatter: string): string[] | null {
89 const inline = /^tools:[ \t]*(\S.*)$/m.exec(frontmatter)?.[1]
90 if (inline !== undefined) {
91 return inline
92 .replace(/^\[|\]$/g, '')
93 .split(',')
94 .map((t) => t.trim().replace(/^["']|["']$/g, ''))
95 .filter((t) => t !== '')
96 }
97 const block = /^tools:[ \t]*\n((?:[ \t]+-[^\n]*\n?)+)/m.exec(frontmatter)?.[1]
98 if (block !== undefined) {
99 return block
100 .split('\n')
101 .map((line) => line.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, ''))
102 .filter((t) => t !== '')
103 }
104 return null
105}
106
107export function agentReadOnly(text: string, agentName: string): boolean | null {
108 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
109 if (!frontmatter) return null
110 const name = /^name:\s*(.+)$/m.exec(frontmatter)?.[1]?.trim().replace(/^["']|["']$/g, '')
111 if (!name || name.toLowerCase() !== agentName.toLowerCase()) return null
112 const tools = toolList(frontmatter)
113 if (tools === null) return false
114 return !tools.some((t) => WRITERS.has(t.toLowerCase()))
115}
116
117export type StagedFs = Fs & {
118 seed(path: string, text: string | null): void
119 drain(): Array<[string, string]>
120}
121
122function missing(path: string): Error {
123 return Object.assign(new Error(`ENOENT: no such file, '${path}'`), { code: 'ENOENT' })
124}
125
126export function stagedFs(): StagedFs {
127 const files = new Map<string, string | null>()
128 const dirty = new Set<string>()
129 return {
130 seed(path, text) {
131 if (!dirty.has(path)) files.set(path, text)
132 },
133 drain() {
134 const out: Array<[string, string]> = []
135 for (const path of dirty) {
136 const text = files.get(path)
137 if (typeof text === 'string') out.push([path, text])
138 }
139 dirty.clear()
140 return out
141 },
142 existsSync(path) {
143 return typeof files.get(path) === 'string'
144 },
145 readFileSync(path) {
146 const text = files.get(path)
147 if (typeof text !== 'string') throw missing(path)
148 return text
149 },
150 writeFileSync(path, data) {
151 files.set(path, data)
152 dirty.add(path)
153 },
154 renameSync(from, to) {
155 const text = files.get(from)
156 if (typeof text !== 'string') throw missing(from)
157 files.set(to, text)
158 files.set(from, null)
159 dirty.delete(from)
160 dirty.add(to)
161 },
162 unlinkSync(path) {
163 files.set(path, null)
164 dirty.delete(path)
165 },
166 mkdirSync() {},
167 }
168}
169
170export type Refusal = { gate: string; reason: string; at: string }
171
172export type Snapshot = {
173 version: string
174 sessionId: string
175 enabled: boolean
176 gates: Array<{ id: GateId; enabled: boolean; enforcedInClaudeCode: boolean }>
177 declaration: { slug: string; intent: string; route: string } | null
178 lastRefusal: Refusal | null
179 counters: { toolCalls: number; filesRead: number; filesWritten: number; delegations: number; refusals: number; agents: number }
180}
181
182export function snapshot(input: {
183 sessionId: string
184 enabled: boolean
185 config: Policy['config']
186 committed: Committed | null
187 lastRefusal: Refusal | null
188 refusals: number
189 agents: number
190}): Snapshot {
191 const policy: Policy = { config: input.config, flags: {}, hatches: {} }
192 const declaration = input.committed?.declaration ?? null
193 return {
194 version: VERSION,
195 sessionId: input.sessionId,
196 enabled: input.enabled,
197 gates: GATE_IDS.map((id) => ({
198 id,
199 enabled: resolveFlag(id, policy).enabled,
200 enforcedInClaudeCode: ENFORCED.includes(id),
201 })),
202 declaration: declaration && { slug: declaration.slug, intent: declaration.intent, route: declaration.route },
203 lastRefusal: input.lastRefusal,
204 counters: {
205 toolCalls: input.committed?.toolCalls ?? 0,
206 filesRead: input.committed?.filesRead.size ?? 0,
207 filesWritten: input.committed?.filesWritten.size ?? 0,
208 delegations: input.committed?.delegations ?? 0,
209 refusals: input.refusals,
210 agents: input.agents,
211 },
212 }
213}
214
215export const CC_DECLARE_SCHEMA = {
216 description: 'Declare intent and route before the first write of a request. To continue the previous request\'s feature, send only slug. A new feature needs intent, route, slug, summary; inline stops there, tracked/forge create .nodd/<slug>/feature.md and may add the other fields.',
217 parameters: {
218 type: 'object',
219 properties: {
220 intent: { type: 'string', enum: ['read-only', 'change'] },
221 route: { type: 'string', enum: ['inline', 'tracked', 'forge'] },
222 slug: { type: 'string' },
223 summary: { type: 'string', description: 'the objective, one sentence' },
224 title: { type: 'string' },
225 problem: { type: 'string' },
226 scope: { type: 'string' },
227 constraints: { type: 'string' },
228 runner: { type: 'string', description: 'verification command, pinned once' },
229 tdd: { type: 'string', enum: ['strict', 'off'], description: 'strict: a failing run before a checkoff' },
230 files: { type: 'array', items: { type: 'string' } },
231 },
232 required: ['slug'],
233 },
234}
235
236export const CC_TASK_SCHEMA = {
237 description: 'Add, check off or reopen a task in the feature document. NODD writes the document.',
238 parameters: {
239 type: 'object',
240 properties: {
241 action: { type: 'string', enum: ['add', 'check', 'reopen'] },
242 id: { type: 'string', description: 'task id, e.g. T1' },
243 title: { type: 'string', description: 'required to add' },
244 slug: { type: 'string' },
245 reason: { type: 'string', description: 'required to reopen' },
246 },
247 required: ['action', 'id', 'slug'],
248 },
249}
250
251export function inScratchpad(path: unknown, sessionId: string): boolean {
252 if (typeof path !== 'string' || sessionId === '') return false
253 const id = sessionId.replace(/[^\w-]/g, '')
254 return new RegExp(`/claude-\\d+/[^/]+/${id}/`).test(path)
255}
256
257const DECLARE_CORE = ['intent', 'route', 'summary'] as const
258
259export function continueDeclaration(
260 input: Record<string, unknown>,
261 previous: Record<string, unknown> | undefined,
262): { input: Record<string, unknown> } | { problem: string } {
263 if (DECLARE_CORE.every((k) => typeof input[k] === 'string' && input[k] !== '')) return { input }
264 if (previous && previous.slug === input.slug) return { input: { ...previous, ...input } }
265 return { problem: `nodd_declare: ${String(input.slug ?? '')} is a new feature here, so send intent, route and summary too. To continue the previous request's feature, send only its slug.` }
266}
267
268export function bashOutcome(r: any): { isError: boolean; content: string } {
269 const text = String(r?.text ?? '')
270 const out = r?.result
271 if (r?.isError === true) {
272 const code = /^\s*Exit code (\d+)/.exec(text)?.[1]
273 return { isError: true, content: code === undefined ? text : `${text}\nCommand exited with code ${code}` }
274 }
275 if (out?.interrupted === true) return { isError: true, content: `${text}\nCommand aborted` }
276 if (typeof out?.timedOutAfterMs === 'number') return { isError: true, content: `${text}\nCommand timed out after ${Math.round(out.timedOutAfterMs / 1000)} seconds` }
277 if (out?.backgroundTaskId) return { isError: true, content: text }
278 return { isError: false, content: text }
279}
280hooks/core/observations.ts 52 lines1// What NODD saw, as it saw it.
2//
3// An `Observation` is a fact from a real pi `tool_result` event: the tool, its
4// input, whether pi reported an error, and the rendered result text. Nothing
5// here interprets that raw material — `src/outcome.ts` derives the five command
6// outcomes from it, and it does so on demand. Storing the interpretation
7// instead of the observation is how "Evidence: tests pass" became worthless in
8// ODD: once collapsed, the evidence can no longer be re-checked.
9
10export type Observation = {
11 toolCallId: string;
12 toolName: string;
13 input: Record<string, unknown>;
14 /** pi's own error flag from the tool result. Not a verdict — an observation. */
15 isError: boolean;
16 /** The rendered result text, including the status line pi's bash appends. */
17 resultText: string;
18 /** ISO timestamp of when the result was observed. */
19 at: string;
20};
21
22/**
23 * A tool call preflighted in the current assistant batch whose result is not
24 * known yet. It deliberately has no result fields: pi preflights siblings
25 * sequentially and runs them concurrently, so a gate that read a result from
26 * here would read evidence that does not exist.
27 */
28export type PendingCall = {
29 toolCallId: string;
30 toolName: string;
31 input: Record<string, unknown>;
32};
33
34export function observation(fields: Observation): Observation {
35 if (!fields?.toolCallId) {
36 throw new Error("an observation requires a toolCallId: it is what makes replay idempotent");
37 }
38 return {
39 toolCallId: fields.toolCallId,
40 toolName: fields.toolName,
41 input: fields.input ?? {},
42 isError: fields.isError,
43 resultText: fields.resultText ?? "",
44 at: fields.at,
45 };
46}
47
48export function pendingCall(fields: PendingCall): PendingCall {
49 if (!fields?.toolCallId) throw new Error("a pending call requires a toolCallId");
50 return { toolCallId: fields.toolCallId, toolName: fields.toolName, input: fields.input ?? {} };
51}
52