@gonrocca/nodd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/extensions/nodd-agents.test.ts +129 -0
  4. package/extensions/nodd-agents.ts +185 -0
  5. package/extensions/nodd-allow.test.ts +75 -0
  6. package/extensions/nodd-allow.ts +76 -0
  7. package/extensions/nodd-enforcement.test.ts +676 -0
  8. package/extensions/nodd-gates.test.ts +108 -0
  9. package/extensions/nodd-gates.ts +121 -0
  10. package/extensions/nodd-kernel.test.ts +114 -0
  11. package/extensions/nodd-kernel.ts +593 -0
  12. package/extensions/nodd-models.test.ts +174 -0
  13. package/extensions/nodd-models.ts +253 -0
  14. package/extensions/nodd-promote.test.ts +150 -0
  15. package/extensions/nodd-promote.ts +96 -0
  16. package/extensions/nodd-prompt.test.ts +87 -0
  17. package/extensions/nodd-tools.test.ts +211 -0
  18. package/package.json +44 -0
  19. package/src/bash-classifier.test.ts +114 -0
  20. package/src/bash-classifier.ts +69 -0
  21. package/src/change-acceptance.test.ts +175 -0
  22. package/src/change-acceptance.ts +98 -0
  23. package/src/config.test.ts +61 -0
  24. package/src/config.ts +103 -0
  25. package/src/delivery.test.ts +156 -0
  26. package/src/delivery.ts +151 -0
  27. package/src/feature-doc.test.ts +120 -0
  28. package/src/feature-doc.ts +292 -0
  29. package/src/gates/authorize.test.ts +62 -0
  30. package/src/gates/authorize.ts +32 -0
  31. package/src/gates/classify.test.ts +54 -0
  32. package/src/gates/classify.ts +45 -0
  33. package/src/gates/delegate.test.ts +127 -0
  34. package/src/gates/delegate.ts +85 -0
  35. package/src/gates/evidence.test.ts +281 -0
  36. package/src/gates/evidence.ts +209 -0
  37. package/src/gates/policy.test.ts +77 -0
  38. package/src/gates/policy.ts +90 -0
  39. package/src/gates/promotion.test.ts +133 -0
  40. package/src/gates/promotion.ts +81 -0
  41. package/src/gates/registry.ts +21 -0
  42. package/src/gates/request.ts +41 -0
  43. package/src/gates/track.test.ts +80 -0
  44. package/src/gates/track.ts +58 -0
  45. package/src/io.test.ts +81 -0
  46. package/src/io.ts +94 -0
  47. package/src/ledger.test.ts +122 -0
  48. package/src/ledger.ts +133 -0
  49. package/src/manifest.test.ts +53 -0
  50. package/src/manifest.ts +61 -0
  51. package/src/models/assign.test.ts +125 -0
  52. package/src/models/assign.ts +138 -0
  53. package/src/models/picker.test.ts +141 -0
  54. package/src/models/picker.ts +98 -0
  55. package/src/models/profiles.test.ts +186 -0
  56. package/src/models/profiles.ts +162 -0
  57. package/src/models/slots.ts +48 -0
  58. package/src/observations.test.ts +61 -0
  59. package/src/observations.ts +51 -0
  60. package/src/odd-prose.test.ts +125 -0
  61. package/src/odd-prose.ts +198 -0
  62. package/src/outcome.test.ts +75 -0
  63. package/src/outcome.ts +63 -0
  64. package/src/promote.test.ts +129 -0
  65. package/src/promote.ts +64 -0
  66. package/src/prompt.test.ts +193 -0
  67. package/src/prompt.ts +136 -0
  68. package/src/review-candidate.test.ts +118 -0
  69. package/src/review-candidate.ts +81 -0
  70. package/src/state.test.ts +153 -0
  71. package/src/state.ts +163 -0
  72. package/test/package-invariants.test.ts +66 -0
  73. package/test/parity-matrix.test.ts +272 -0
  74. package/test/readme-contract.test.ts +182 -0
@@ -0,0 +1,593 @@
1
+ // nodd-kernel — the pi-facing half of NODD.
2
+ //
3
+ // This is the only place pi events are registered. Everything it learns is
4
+ // translated into `Observation` values and folded by the pure reducer in
5
+ // `src/state.ts`, so every rule stays testable without a pi runtime.
6
+ //
7
+ // In this capability the kernel is an observer: it blocks nothing. The gates
8
+ // land in later tasks, and when they do, a refusal returns `{ block, reason }`
9
+ // and never `terminate` — pi stops the agent early only when *every* finalized
10
+ // result in a batch is terminating (`types.d.ts:781-786`), so aborting one
11
+ // sibling would take its innocent siblings with it.
12
+
13
+ import { join } from "node:path";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { observation, pendingCall, type Observation } from "../src/observations.ts";
16
+ import { emptyState, fold, type Committed, type NoddState } from "../src/state.ts";
17
+ import {
18
+ emptyDoc,
19
+ parseFeatureDoc,
20
+ renderFeatureDoc,
21
+ type FeatureDoc,
22
+ type Intent,
23
+ type Route,
24
+ } from "../src/feature-doc.ts";
25
+ import { writeVerified } from "../src/io.ts";
26
+ import { renderPrompt } from "../src/prompt.ts";
27
+ import { consumeHatch, emptyPolicy, type GateDecision, type Policy } from "../src/gates/policy.ts";
28
+ import { parseConfig, noddConfigPath } from "../src/config.ts";
29
+ import { GATE_IDS } from "../src/gates/registry.ts";
30
+ import { authorizeGate } from "../src/gates/authorize.ts";
31
+ import { classifyGate } from "../src/gates/classify.ts";
32
+ import { trackGate } from "../src/gates/track.ts";
33
+ import { delegateGate } from "../src/gates/delegate.ts";
34
+ import { evidenceGate, isDeclaredRunner } from "../src/gates/evidence.ts";
35
+ import { promotionGate } from "../src/gates/promotion.ts";
36
+ import { isFileWrite, targetPath, type GateRequest } from "../src/gates/request.ts";
37
+ import { appendRecord, readLedger } from "../src/ledger.ts";
38
+ import { isSuccess, parseOutcome } from "../src/outcome.ts";
39
+ import { candidateFor, candidateIdentity } from "../src/review-candidate.ts";
40
+ import { reopenTask } from "../src/change-acceptance.ts";
41
+
42
+ /** The NODD entry type appended to the session so a reload can rebuild state. */
43
+ export const OBSERVATION_ENTRY = "nodd:observation";
44
+
45
+ type ToolCallEvent = { toolName: string; toolCallId: string; input?: Record<string, unknown> };
46
+ type ToolResultEvent = ToolCallEvent & { isError?: boolean; content?: unknown };
47
+ /**
48
+ * pi's `session_start` carries no entries (`types.d.ts:416-422`): the session log
49
+ * is reached through the context, and custom entries arrive as
50
+ * `{ type: "custom", customType, data }` (`session-manager.d.ts:69-73`).
51
+ */
52
+ type SessionEntry = { type?: string; customType?: string; data?: unknown };
53
+ type SessionStartContext = { sessionManager?: { getEntries?(): SessionEntry[] } };
54
+ type AgentStartEvent = { systemPrompt?: unknown };
55
+
56
+ /** The slice of pi's API this extension uses. Declared locally: no pi import. */
57
+ type PiApi = {
58
+ on(event: string, handler: (event: never) => unknown): void;
59
+ appendEntry?(type: string, data?: unknown): void;
60
+ registerTool?(name: string, options: unknown): void;
61
+ };
62
+
63
+ const INTENTS: readonly Intent[] = ["read-only", "change"];
64
+ const ROUTES: readonly Route[] = ["inline", "tracked", "forge"];
65
+
66
+ /** `.nodd/<slug>/feature.md` — the durable, extension-owned NODD artifact. */
67
+ export function featureDocPath(cwd: string, slug: string): string {
68
+ return join(cwd, ".nodd", slug, "feature.md");
69
+ }
70
+
71
+ export type ToolReply = { ok: boolean; text: string };
72
+
73
+ export type DeclareArgs = {
74
+ intent: Intent;
75
+ route: Route;
76
+ slug: string;
77
+ summary: string;
78
+ title?: string;
79
+ /** The verification command. `gate-evidence` accepts runs of this and nothing else. */
80
+ runner?: string;
81
+ tdd?: "strict" | "off";
82
+ /** The files this work says it will touch. `gate-promotion` compares against it. */
83
+ files?: string[];
84
+ };
85
+
86
+ export type TaskArgs = {
87
+ action: "add" | "check" | "reopen";
88
+ id: string;
89
+ title?: string;
90
+ slug: string;
91
+ /** Required to reopen a checked task (`routing.go:97`). */
92
+ reason?: string;
93
+ };
94
+
95
+ function resultText(content: unknown): string {
96
+ if (typeof content === "string") return content;
97
+ if (Array.isArray(content)) {
98
+ return content.map((part) => (typeof part === "string" ? part : String((part as { text?: string })?.text ?? ""))).join("\n");
99
+ }
100
+ return content === undefined || content === null ? "" : String(content);
101
+ }
102
+
103
+ export type Kernel = {
104
+ state: NoddState;
105
+ /**
106
+ * Record a classification. On a `tracked`/`forge` route it creates the
107
+ * feature doc and returns ODD's one-line report (`routing.go:49`). The model
108
+ * supplies fields, never document text: a doc the model can author is a doc
109
+ * the model can forge, and then evidence is prose again.
110
+ */
111
+ declare(args: DeclareArgs): ToolReply;
112
+ /** Add or check off a task. The extension renders the doc. */
113
+ task(args: TaskArgs): ToolReply;
114
+ onToolCall(event: ToolCallEvent): void;
115
+ onToolResult(event: ToolResultEvent): Observation;
116
+ /**
117
+ * Rebuild what a new process can legitimately know from a previous one.
118
+ *
119
+ * Everything a gate needs for *context* comes back: the declaration, the files
120
+ * read and written, the delegation and tool-call counters. What deliberately
121
+ * does **not** come back is evidence — `commandResults`, which is what
122
+ * `gate-evidence` reads.
123
+ *
124
+ * Round 1 replayed those too, so a resumed process with zero commands run
125
+ * checked tasks off while the README promised the opposite. The entries are
126
+ * not forgeable by the model — the kernel writes them from real tool results,
127
+ * and `gate-track` refuses model writes into `.nodd/` — but "not forged" is a
128
+ * weaker claim than "observed here", and evidence is the one place NODD
129
+ * insists on the stronger one. A run this process did not see is a report
130
+ * about the past; the remedy is one command.
131
+ */
132
+ replay(entries: SessionEntry[] | undefined): void;
133
+ /**
134
+ * The evidence view. It returns `Committed` — not `NoddState` — so a gate
135
+ * reading evidence physically cannot see a sibling call that has not
136
+ * finished. The hazard is made unrepresentable rather than documented.
137
+ */
138
+ evidenceView(): Committed;
139
+ /** Run the ordered registry against one call. `null` means let it run. */
140
+ checkCall(request: GateRequest): { block: true; reason: string } | null;
141
+ setPolicy(policy: Policy): void;
142
+ policy(): Policy;
143
+ };
144
+
145
+ export function createKernel(
146
+ now: () => string = () => new Date().toISOString(),
147
+ cwd: string = process.cwd(),
148
+ ): Kernel {
149
+ const state = emptyState();
150
+ // Mutable because `/nodd-allow` grants a hatch mid-session and a refusal
151
+ // spends it. The policy is session state, not a constant.
152
+ let policy: Policy = emptyPolicy();
153
+
154
+ const readDoc = (slug: string): FeatureDoc | null => {
155
+ const path = featureDocPath(cwd, slug);
156
+ if (!existsSync(path)) return null;
157
+ return parseFeatureDoc(readFileSync(path, "utf8")).doc;
158
+ };
159
+
160
+ const saveDoc = (doc: FeatureDoc): ToolReply => {
161
+ const path = featureDocPath(cwd, doc.slug);
162
+ const previous = existsSync(path) ? readFileSync(path, "utf8") : undefined;
163
+ const result = writeVerified(path, renderFeatureDoc(doc), { expectedPrevious: previous });
164
+ if (!result.ok) return { ok: false, text: `nodd: ${result.limitation}` };
165
+ const conflict = result.conflictPath ? ` (previous version preserved at ${result.conflictPath})` : "";
166
+ return { ok: true, text: `.nodd/${doc.slug}/feature.md created with ${doc.tasks.length} tasks${conflict}` };
167
+ };
168
+
169
+ return {
170
+ state,
171
+
172
+ declare(args) {
173
+ if (!INTENTS.includes(args.intent)) {
174
+ return { ok: false, text: `nodd_declare: intent must be one of ${INTENTS.join(", ")}` };
175
+ }
176
+ if (!ROUTES.includes(args.route)) {
177
+ return { ok: false, text: `nodd_declare: route must be one of ${ROUTES.join(", ")}` };
178
+ }
179
+ if (!args.slug) return { ok: false, text: "nodd_declare: slug is required" };
180
+
181
+ // `inline` is small, understood work: it creates no durable artifact
182
+ // (`routing.go:94`). Only tracked and forge routes get a feature doc.
183
+ if (args.route === "inline") {
184
+ return { ok: true, text: `route inline declared for ${args.slug}; no feature document created` };
185
+ }
186
+
187
+ const existing = readDoc(args.slug);
188
+ const doc = existing ?? emptyDoc({ slug: args.slug, title: args.title || args.slug });
189
+
190
+ // "Declared up front" is only a property if it cannot be re-declared
191
+ // afterwards. Re-declaring rewrote `## Verification` wholesale, so a
192
+ // refused checkoff was repaired by naming the command that did pass as
193
+ // the runner -- the round-1 echo attack with one extra step. A runner is
194
+ // pinned once; changing it means a new feature, or `/nodd-allow`.
195
+ const requested = args.runner && args.runner !== "" ? args.runner : null;
196
+
197
+ // Strict TDD is "a RED run of the declared runner before the GREEN". With
198
+ // no runner there is no such run to require, and the gate silently
199
+ // skipped the check while the document still read `- tdd: strict`. A doc
200
+ // asserting a discipline nothing enforces is the exact failure NODD
201
+ // exists to prevent, so the declaration is refused instead.
202
+ if (args.tdd === "strict" && (requested ?? doc.verification.runner) === null) {
203
+ return {
204
+ ok: false,
205
+ 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.",
206
+ };
207
+ }
208
+
209
+ // The runner is pinned once; the discipline it is run under has to be
210
+ // pinned the same way. Omitting `tdd` on a re-declaration rewrote the doc
211
+ // to `- tdd: off` silently, and a GREEN with no RED then checked the task
212
+ // off -- the re-pinning attack with one word removed instead of one added.
213
+ if (doc.verification.tdd === "strict" && args.tdd !== "strict") {
214
+ return {
215
+ ok: false,
216
+ 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.`,
217
+ };
218
+ }
219
+
220
+ if (doc.verification.runner !== null && requested !== null && requested !== doc.verification.runner) {
221
+ return {
222
+ ok: false,
223
+ 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.`,
224
+ };
225
+ }
226
+
227
+ doc.objective = args.summary;
228
+ doc.route = { intent: args.intent, route: args.route };
229
+ // Declared once and written by the extension, so the runner a checkoff is
230
+ // measured against is not a string the model can pick per checkoff.
231
+ doc.verification = {
232
+ runner: requested ?? doc.verification.runner,
233
+ tdd: args.tdd === "strict" ? "strict" : "off",
234
+ source: "nodd_declare",
235
+ files: [...new Set((args.files ?? []).filter((file) => typeof file === "string" && file !== ""))],
236
+ };
237
+ return saveDoc(doc);
238
+ },
239
+
240
+ task(args) {
241
+ const doc = readDoc(args.slug);
242
+ if (!doc) {
243
+ return { ok: false, text: `nodd_task: no feature document for ${args.slug}; call nodd_declare first` };
244
+ }
245
+
246
+ if (args.action === "add") {
247
+ if (doc.tasks.some((t) => t.id === args.id)) {
248
+ return { ok: false, text: `nodd_task: ${args.id} already exists` };
249
+ }
250
+ doc.tasks.push({ id: args.id, title: args.title ?? args.id, checked: false });
251
+ const saved = saveDoc(doc);
252
+ return saved.ok ? { ok: true, text: `${args.id} added to .nodd/${doc.slug}/feature.md` } : saved;
253
+ }
254
+
255
+ if (args.action === "reopen") {
256
+ const reopened = reopenTask(doc, args.id, args.reason ?? "");
257
+ if (!reopened.ok) return { ok: false, text: `nodd_task: ${reopened.problem}` };
258
+ const saved = saveDoc(reopened.doc);
259
+ return saved.ok ? { ok: true, text: `${args.id} reopened in .nodd/${doc.slug}/feature.md` } : saved;
260
+ }
261
+
262
+ const index = doc.tasks.findIndex((t) => t.id === args.id);
263
+ if (index < 0) return { ok: false, text: `nodd_task: ${args.id} is not in the document` };
264
+
265
+ // A checkoff is gated like any other claim: the evidence gate answers
266
+ // from observed command results, and what it returns is what gets
267
+ // written. NODD never invents a command it did not see run.
268
+ const ledger = ledgerPath(cwd, doc.slug);
269
+ const { records } = readLedger(ledger);
270
+ const verdict = evidenceGate(
271
+ state.committed,
272
+ records,
273
+ {
274
+ task: args.id,
275
+ // The task's own files when it declared some, the whole session's
276
+ // writes otherwise. Either way a real timestamp, not a bash proxy.
277
+ ...lastWrite(state.committed, doc.verification.files),
278
+ runner: doc.verification.runner,
279
+ ...(doc.verification.tdd === "strict" && doc.verification.runner
280
+ ? { tdd: { mode: "strict" as const, source: doc.verification.source, runner: doc.verification.runner } }
281
+ : {}),
282
+ },
283
+ policy,
284
+ );
285
+ if (!verdict.allow) return { ok: false, text: verdict.reason };
286
+
287
+ // Record the evidence this checkoff relied on. Without this call the
288
+ // ledger never exists, `readLedger` always returns `[]`, and every
289
+ // integrity rule the README documents is unreachable code.
290
+ const used = state.committed.commandResults.find((run) => run.toolCallId === verdict.observed.toolCallId);
291
+ if (used) {
292
+ try {
293
+ appendRecord(ledger, {
294
+ toolCallId: used.toolCallId,
295
+ tool: "bash",
296
+ command: used.command,
297
+ outcome: parseOutcome(used.isError, used.resultText),
298
+ at: used.at,
299
+ });
300
+ } catch {
301
+ // A ledger NODD cannot write is a reported limitation, not a reason to
302
+ // discard a checkoff the gate already allowed on observed evidence.
303
+ }
304
+ }
305
+
306
+ // The candidate is the observed commit SHA, or `pending-commit` when this
307
+ // session has seen no commit. Never the checkbox (`routing.go:51`,`:102`).
308
+ const task = doc.tasks[index];
309
+ doc.tasks[index] = {
310
+ id: task.id,
311
+ title: task.title,
312
+ checked: true,
313
+ evidence: { command: verdict.observed.command, outcome: verdict.observed.outcome },
314
+ candidate: candidateIdentity(candidateFor(state.committed)),
315
+ };
316
+ const saved = saveDoc(doc);
317
+ return saved.ok ? { ok: true, text: `${args.id} checked in .nodd/${doc.slug}/feature.md` } : saved;
318
+ },
319
+
320
+ checkCall(request) {
321
+ // Registry order, first refusal wins: one call never collects two
322
+ // messages about the same missing declaration.
323
+ const decisions: Array<[(typeof GATE_IDS)[number], () => GateDecision]> = [
324
+ ["authorize", () => authorizeGate(state.committed, request, policy)],
325
+ ["classify", () => classifyGate(state.committed, request, policy, state.pending)],
326
+ ["track", () => trackGate(state.committed, request, policy, (slug) => existsSync(featureDocPath(cwd, slug)))],
327
+ ["delegate", () => delegateGate(state.committed, request, policy, state.pending)],
328
+ ["promotion", () => promotionGate(promotionSignals(state.committed, request), request, policy)],
329
+ ];
330
+
331
+ for (const [gate, evaluate] of decisions) {
332
+ const decision = evaluate();
333
+ if (decision.allow) continue;
334
+
335
+ // A one-shot override is spent by the refusal it prevents — never
336
+ // implicitly, never across gates, never twice.
337
+ const hatch = consumeHatch(policy, gate);
338
+ if (hatch) {
339
+ policy = hatch.policy;
340
+ return null;
341
+ }
342
+ // `terminate` is deliberately never set: a blocked call stops that
343
+ // call, not the agent.
344
+ return { block: true, reason: decision.reason };
345
+ }
346
+ return null;
347
+ },
348
+
349
+ setPolicy(next) {
350
+ policy = next;
351
+ },
352
+
353
+ policy() {
354
+ return policy;
355
+ },
356
+
357
+ onToolCall(event) {
358
+ state.pending.set(event.toolCallId, pendingCall({
359
+ toolCallId: event.toolCallId,
360
+ toolName: event.toolName,
361
+ input: event.input ?? {},
362
+ }));
363
+ },
364
+ onToolResult(event) {
365
+ state.pending.delete(event.toolCallId);
366
+ const obs = observation({
367
+ toolCallId: event.toolCallId,
368
+ toolName: event.toolName,
369
+ input: event.input ?? {},
370
+ isError: event.isError === true,
371
+ resultText: resultText(event.content),
372
+ at: now(),
373
+ });
374
+ state.committed = fold(state.committed, obs);
375
+ return obs;
376
+ },
377
+ replay(entries) {
378
+ for (const entry of entries ?? []) {
379
+ const isNodd = entry?.customType === OBSERVATION_ENTRY || entry?.type === OBSERVATION_ENTRY;
380
+ if (!isNodd) continue;
381
+ const data = entry.data as Observation | undefined;
382
+ if (!data?.toolCallId) continue;
383
+
384
+ state.committed = fold(state.committed, observation(data));
385
+ // The context is rebuilt; the evidence is not. A command another process
386
+ // observed is not a command this one observed.
387
+ state.committed = { ...state.committed, commandResults: [] };
388
+ }
389
+ },
390
+ evidenceView() {
391
+ return state.committed;
392
+ },
393
+ };
394
+ }
395
+
396
+ const DECLARE_SCHEMA = {
397
+ description:
398
+ "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.",
399
+ parameters: {
400
+ type: "object",
401
+ properties: {
402
+ intent: { type: "string", enum: INTENTS, description: "read-only work never writes" },
403
+ route: { type: "string", enum: ROUTES, description: "inline stays small; tracked and forge create a feature document" },
404
+ slug: { type: "string", description: "filename-safe feature identity" },
405
+ summary: { type: "string", description: "the objective, in one or two sentences" },
406
+ title: { type: "string", description: "human-readable feature title" },
407
+ runner: {
408
+ type: "string",
409
+ description:
410
+ "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.",
411
+ },
412
+ tdd: { type: "string", enum: ["strict", "off"], description: "strict requires an observed failing run before a checkoff" },
413
+ files: {
414
+ type: "array",
415
+ items: { type: "string" },
416
+ description: "the files this work will touch; writing more distinct files than declared escalates to promotion",
417
+ },
418
+ },
419
+ required: ["intent", "route", "slug", "summary"],
420
+ },
421
+ };
422
+
423
+ const TASK_SCHEMA = {
424
+ description:
425
+ "Add a task to the feature document, check one off, or reopen a checked one. NODD writes the document; you never edit it directly.",
426
+ parameters: {
427
+ type: "object",
428
+ properties: {
429
+ action: { type: "string", enum: ["add", "check", "reopen"] },
430
+ id: { type: "string", description: "stable task id, e.g. T1" },
431
+ title: { type: "string", description: "required when adding" },
432
+ slug: { type: "string", description: "the feature slug" },
433
+ reason: { type: "string", description: "required when reopening: why the completed result no longer holds" },
434
+ },
435
+ required: ["action", "id", "slug"],
436
+ },
437
+ };
438
+
439
+ /** pi's tool input, as a plain object a gate can read. */
440
+ function normalizeInput(input: unknown): Record<string, unknown> {
441
+ return typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {};
442
+ }
443
+
444
+ function ledgerPath(cwd: string, slug: string): string {
445
+ return join(cwd, ".nodd", slug, "ledger.json");
446
+ }
447
+
448
+ /**
449
+ * The most recent observed write that evidence must postdate.
450
+ *
451
+ * Reads `filesWritten`, which is where writes actually are. Round 1 read
452
+ * `commandResults` — bash only — so a `write`/`edit` after a green run moved
453
+ * nothing, and the stale run certified the edit it predated.
454
+ *
455
+ * Scoped to the declared files when there are any: a task is certified by a run
456
+ * postdating *its* edits, not every edit in the session.
457
+ */
458
+ function lastWrite(committed: Committed, declaredFiles: string[] = []): { lastWriteAt: string; lastWriteSeq: number } {
459
+ const relevant = declaredFiles.length > 0
460
+ ? [...committed.filesWritten].filter(([path]) => declaredFiles.includes(path))
461
+ : [...committed.filesWritten];
462
+
463
+ let latest = { at: new Date(0).toISOString(), seq: 0 };
464
+ for (const [, write] of relevant) {
465
+ if (write.seq > latest.seq) latest = write;
466
+ }
467
+ return { lastWriteAt: latest.at, lastWriteSeq: latest.seq };
468
+ }
469
+
470
+ /**
471
+ * What `gate-promotion` may look at, derived from what this kernel observed.
472
+ *
473
+ * Round 1 returned zeroes here with a comment saying a fabricated signal is
474
+ * worse than an absent one — which was right, and was then used to justify
475
+ * shipping the gate wired to constants, so it could never fire. Both real
476
+ * signals are derivable from state the kernel already holds:
477
+ *
478
+ * - `declaredFiles` from the declaration's own file list;
479
+ * - `observedFiles` from `filesWritten`;
480
+ * - `consecutiveFailures` from the trailing non-success runs of the declared
481
+ * runner, which is the only command whose failures say the plan is wrong.
482
+ *
483
+ * The third specified signal, "the user asked", was not derivable and has been
484
+ * removed from the product rather than faked (see `src/gates/promotion.ts`).
485
+ */
486
+ function promotionSignals(committed: Committed, request: GateRequest) {
487
+ const declaration = committed.declaration;
488
+ const runner = declaration?.runner ?? null;
489
+
490
+ // Only runs of the declared runner count, and only the trailing streak: a
491
+ // failing `grep` is not a failing plan, and a success clears the streak.
492
+ let consecutiveFailures = 0;
493
+ if (runner !== null) {
494
+ for (let i = committed.commandResults.length - 1; i >= 0; i--) {
495
+ const run = committed.commandResults[i];
496
+ if (!isDeclaredRunner(run.command, runner)) continue;
497
+ if (isSuccess(parseOutcome(run.isError, run.resultText))) break;
498
+ consecutiveFailures += 1;
499
+ }
500
+ }
501
+
502
+ // The file this call is about to write counts too. Write intent is known at
503
+ // preflight, and a gate that only saw finished writes would refuse the
504
+ // divergence one file late — after the divergent write already happened.
505
+ const files = new Set(committed.filesWritten.keys());
506
+ const target = targetPath(request);
507
+ if (target && isFileWrite(request)) files.add(target);
508
+
509
+ return {
510
+ slug: declaration?.slug ?? "",
511
+ consecutiveFailures,
512
+ failedTaskId: consecutiveFailures > 0 ? runner : null,
513
+ declaredFiles: declaration?.files.length ?? 0,
514
+ observedFiles: files.size,
515
+ };
516
+ }
517
+
518
+ /** Gate flags as configured. An unreadable config means nobody chose. */
519
+ function readPolicy(): Policy {
520
+ try {
521
+ const { config } = parseConfig(readFileSync(noddConfigPath(), "utf8"));
522
+ return { ...emptyPolicy(), config: config.gates };
523
+ } catch {
524
+ return emptyPolicy();
525
+ }
526
+ }
527
+
528
+ export default function register(pi?: PiApi, cwd: string = process.cwd()): Kernel {
529
+ const kernel = createKernel(undefined, cwd);
530
+ if (!pi || typeof pi.on !== "function") return kernel;
531
+
532
+ // Flags the user set persist into the session's policy. `/nodd-allow` adds
533
+ // one-shot hatches on top of this at runtime.
534
+ kernel.setPolicy(readPolicy());
535
+
536
+ pi.registerTool?.("nodd_declare", {
537
+ ...DECLARE_SCHEMA,
538
+ handler: (args: DeclareArgs) => kernel.declare(args).text,
539
+ });
540
+ pi.registerTool?.("nodd_task", {
541
+ ...TASK_SCHEMA,
542
+ handler: (args: TaskArgs) => kernel.task(args).text,
543
+ });
544
+
545
+ // Enforcement. The gates are useless unless they run here: this is the only
546
+ // point in a session where NODD can refuse a call before it happens.
547
+ pi.on("tool_call", ((event: ToolCallEvent) => {
548
+ let decision: { block: true; reason: string } | null = null;
549
+ try {
550
+ decision = kernel.checkCall({ toolName: event?.toolName ?? "", input: normalizeInput(event?.input) });
551
+ } catch {
552
+ // A gate that throws must not break the session. Failing open here is
553
+ // deliberate: NODD refuses work it understands, never work it crashed on.
554
+ decision = null;
555
+ }
556
+ // Record the call either way. A refused call still happened, and the
557
+ // counters that decide the next refusal must see it.
558
+ kernel.onToolCall(event);
559
+ return decision ?? undefined;
560
+ }) as never);
561
+
562
+ pi.on("tool_result", ((event: ToolResultEvent) => {
563
+ const obs = kernel.onToolResult(event);
564
+ try {
565
+ pi.appendEntry?.(OBSERVATION_ENTRY, obs);
566
+ } catch {
567
+ // Session persistence is best effort: the durable truth is on disk.
568
+ }
569
+ }) as never);
570
+
571
+ pi.on("session_start", ((_event: unknown, ctx: SessionStartContext) => {
572
+ kernel.replay(ctx?.sessionManager?.getEntries?.());
573
+ }) as never);
574
+
575
+ // Chained, never replaced (`types.d.ts:806-810`): the incoming prompt is
576
+ // returned byte-for-byte with NODD's two blocks appended, so another
577
+ // extension's contribution survives ours. Rebuilt from current state each
578
+ // turn rather than accumulated, and budgeted in `src/prompt.ts`.
579
+ pi.on("before_agent_start", ((event: AgentStartEvent) => {
580
+ try {
581
+ const incoming = typeof event?.systemPrompt === "string" ? event.systemPrompt : "";
582
+ const { blockA, blockB } = renderPrompt(kernel.evidenceView(), kernel.policy());
583
+ const appended = [blockA, blockB].filter((block) => block !== "").join("\n\n");
584
+ return { systemPrompt: incoming === "" ? appended : `${incoming}\n\n${appended}` };
585
+ } catch {
586
+ // A prompt NODD cannot render must not stop the turn: leaving the
587
+ // incoming prompt untouched loses guidance, never the session.
588
+ return undefined;
589
+ }
590
+ }) as never);
591
+
592
+ return kernel;
593
+ }