SLOPSHOPPER

nodd

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

newguardcommandprompttoolagents
★ 4v0.1.0NOASSERTIONupdated 2026-10-13gonzalonicolasr/nodd/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nodd
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nodd ⎿ nodd: nodd apagado en Claude Code (no bloquea nada) ⎿ nodd: declaración: ninguna ⎿ nodd: gates (compartidos con pi en ~/.pi/nodd.json): ⎿ nodd: authorize on default activo en Claude Code ⎿ nodd: classify on default activo en Claude Code ⎿ nodd: track on default activo en Claude Code ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

NODD para Claude Code

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.

Instalarlo

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"

Comandos

ComandoQué hace
/nodd o /nodd statusSi está prendido, la declaración actual, los gates, overrides pendientes y el último rechazo
/nodd on / /nodd offEl interruptor general: vale para todas las sesiones, también las que ya están abiertas. Apagado no frena nada
/nodd gatesLos 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

Qué gates aplica

GateEn Claude Code
authorizeactivo: con intent: read-only no se escribe ni se delega a un agente que pueda escribir
classifyactivo: no hay primera escritura sin nodd_declare
trackactivo: en ruta tracked/forge hace falta .nodd/<slug>/feature.md, y .nodd/** lo escribe NODD, no el modelo
delegatependiente en Claude Code
evidenceactivo: 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
promotionpendiente 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).

Una declaración por pedido, como en ODD

ODD le pide al modelo que clasifique cada pedido antes de escribir. NODD lo hace igual, pero con código:

  • Al arrancar la conversación el modelo recibe el protocolo (autorizar, explorar, clasificar, registrar, implementar, cerrar) como un bloque de contexto nodd. No hace falta que lo frenen para enterarse.
  • Cada pedido tuyo borra la declaración anterior y le agrega al modelo un recordatorio que vos no ves. Su primera escritura de ese pedido pide un nodd_declare nuevo, aunque siga con el mismo slug.
  • No es un pedido nuevo: la notificación de un agente en background, un mensaje de otra sesión, un /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.

Lo irreversible no lo corre el modelo

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.

Cierre observado

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.

Cosas que conviene saber

  • El override es de Claude Code. /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.
  • Subagentes. Cada subagente tiene su propio kernel, así sus llamadas no inflan los contadores del principal. Arranca con la declaración que tenía su padre en ese momento: si el principal declaró, el subagente puede escribir dentro de esa ruta; si no declaró nadie, el subagente también se frena (y el rechazo le dice que avise o que declare).
  • Los agentes de forge pasan siempre. Las llamadas de un agente de fase 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.
  • Los rechazos nombran /nodd allow <gate>, el comando de Claude Code. El kernel compartido escribe /nodd-allow, que es el de pi, y el mod lo traduce.
  • El interruptor es uno solo. Cada sesión lo relee antes de cada tool y de cada pedido, así que un /nodd on|off llega a las sesiones que ya estaban abiertas sin reiniciarlas.
  • Agentes de sólo lectura. Para 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.
  • Dónde va .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.
  • Estado para NERV. Cada sesión escribe el suyo en ~/.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.

Desarrollo

./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.

Source 31 files
hooks/register.ts 623 lines
1import { 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}
623
hooks/core/kernel.ts 666 lines
1import { 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}
666
hooks/core/gates/registry.ts 22 lines
1// 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}
22
hooks/core/gates/policy.ts 84 lines
1// 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}
84
hooks/core/config-core.ts 116 lines
1// 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}
116
hooks/core/path.ts 37 lines
1function 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}
37
hooks/core/close-report.ts 217 lines
1import 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}
217
hooks/core/state.ts 194 lines
1// 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}
194
hooks/core/destructive.ts 47 lines
1function 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}
47
hooks/core/scratch.ts 64 lines
1import { 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}
64
hooks/cc.ts 280 lines
1import 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}
280
hooks/core/observations.ts 52 lines
1// 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