@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,209 @@
1
+ // gate-evidence — the one that makes the rest worth having.
2
+ //
3
+ // ODD says "evidence is what you observed, not what you expect" and then lets
4
+ // the model write `Evidence: tests pass` with nothing behind it. Here a checkoff
5
+ // is refused unless a `success` outcome was observed, from a real command, after
6
+ // the task's last write.
7
+ //
8
+ // Four independent facts must line up, and each is checked against observation
9
+ // rather than assertion:
10
+ //
11
+ // 1. a command ran — a `tool_result`, not an assistant sentence
12
+ // 2. it succeeded — `isError === false`, i.e. exit 0 by pi's
13
+ // construction; `exit`/`aborted`/`timeout`/
14
+ // `unknown` never qualify (T019)
15
+ // 3. it ran after the edit — a green run predating the change proves
16
+ // nothing about the change
17
+ // 4. it was *the declared check* — the runner recorded at declaration time,
18
+ // not whatever string the model picked
19
+ //
20
+ // Fact 4 is what makes the other three worth having. pi's bash tool exits 0 for
21
+ // `echo 'I have verified that all tests pass'` just as it does for `npm test`,
22
+ // so a gate that accepts any exit-0 accepts the model's own sentence wearing a
23
+ // command's clothes — `Evidence: tests pass` again, now in NODD's format. The
24
+ // model does not choose the runner: it is declared once, lands in the feature
25
+ // doc's `## Verification` section, and the gate compares against it.
26
+ //
27
+ // Honest limit: when no runner was declared, fact 4 cannot be checked and is
28
+ // skipped rather than faked. The artifact says so in the evidence line itself —
29
+ // `success (runner not pinned)` — because a reader auditing a checkoff has to
30
+ // be able to tell a run certified by the declared runner from one certified by
31
+ // whatever exit-0 string was handy. Saying it only in `## Verification` leaves
32
+ // the line that is actually read indistinguishable from a pinned one.
33
+ //
34
+ // Plus a fourth, per the ledger integrity rule: the record must be one this
35
+ // kernel observed. A record on disk is a claim about the past; only the
36
+ // committed observation set makes it evidence.
37
+ //
38
+ // With the gate off the checkoff proceeds but the artifact says
39
+ // `observed: none (gate disabled)`. A disabled gate may stop enforcing; it may
40
+ // not make the document assert something that was never verified.
41
+
42
+ import type { Committed } from "../state.ts";
43
+ import { classifyRecords, describeDegraded, type LedgerRecord } from "../ledger.ts";
44
+ import { describeOutcome, isSuccess, parseOutcome } from "../outcome.ts";
45
+ import { refuse, resolveFlag, type Policy, type Remedy } from "./policy.ts";
46
+
47
+ export type TddContext = { mode: "strict" | "off"; source: string; runner: string };
48
+
49
+ export type CheckRequest = {
50
+ task: string;
51
+ /** When the task's last observed write happened. Evidence must postdate it. */
52
+ lastWriteAt: string;
53
+ /**
54
+ * That write's position in observation order. Two tool results can share a
55
+ * millisecond, so the ordering is decided here and `lastWriteAt` is what the
56
+ * refusal quotes. `0` means nothing has been written yet.
57
+ */
58
+ lastWriteSeq?: number;
59
+ /** The declared verification command. `null` when the declaration pinned none. */
60
+ runner: string | null;
61
+ tdd?: TddContext;
62
+ };
63
+
64
+ export type ObservedEvidence = {
65
+ command: string;
66
+ outcome: string;
67
+ at?: string;
68
+ /** The call this evidence came from, so the caller can record exactly it. */
69
+ toolCallId?: string;
70
+ tdd?: TddContext;
71
+ };
72
+
73
+ export type EvidenceDecision =
74
+ | { allow: true; observed: ObservedEvidence }
75
+ | { allow: false; gate: "evidence"; reason: string; remedy: Remedy };
76
+
77
+ const REMEDY = "run the verification command, then check the task off once it is observed to succeed";
78
+
79
+ /**
80
+ * Whether an observed command *is* the declared runner. Prefix match on whole
81
+ * tokens, so `npm test -- src/login.test.ts` counts and `echo npm test` does
82
+ * not: a runner narrowed to one file is the ordinary way a task verifies
83
+ * itself, while the runner quoted inside another command is not a run of it.
84
+ *
85
+ * Exported because `gate-promotion`'s failure streak counts runs of the same
86
+ * declared runner, and the two gates must agree on what "a run of it" means.
87
+ */
88
+ export function isDeclaredRunner(command: string, runner: string): boolean {
89
+ const actual = command.trim().split(/\s+/);
90
+ const declared = runner.trim().split(/\s+/);
91
+ if (declared.length === 0 || actual.length < declared.length) return false;
92
+ return declared.every((token, i) => actual[i] === token);
93
+ }
94
+
95
+ function deny(reason: string, remedy: string = REMEDY): EvidenceDecision {
96
+ const refusal = refuse("evidence", reason, remedy);
97
+ return { allow: false, gate: "evidence", reason: refusal.reason, remedy: refusal.remedy };
98
+ }
99
+
100
+ export function evidenceGate(
101
+ committed: Committed,
102
+ ledger: LedgerRecord[],
103
+ request: CheckRequest,
104
+ policy: Policy,
105
+ ): EvidenceDecision {
106
+ if (!resolveFlag("evidence", policy).enabled) {
107
+ return { allow: true, observed: { command: "none", outcome: "none (gate disabled)" } };
108
+ }
109
+
110
+ const runs = committed.commandResults.map((result) => ({
111
+ ...result,
112
+ outcome: parseOutcome(result.isError, result.resultText),
113
+ }));
114
+
115
+ // A ledger record this kernel did not observe is unverified, not evidence; one
116
+ // it *did* observe and that says something else is a mismatch, which is a
117
+ // different and more serious failure. Passing the observed calls is what makes
118
+ // the second branch reachable at all.
119
+ const observedIds = new Set(runs.map((run) => run.toolCallId));
120
+ const observedCalls = new Map(runs.map((run) => [
121
+ run.toolCallId,
122
+ { command: run.command, outcome: describeOutcome(run.outcome) },
123
+ ]));
124
+ const { degraded } = classifyRecords(ledger, observedIds, observedCalls);
125
+
126
+ const contradicted = degraded.filter((entry) => entry.reason === "mismatch");
127
+ if (contradicted.length > 0) {
128
+ // Fail closed and say which way: the ledger on disk disagrees with what this
129
+ // process watched happen, so neither can be trusted to back a checkoff.
130
+ return deny(
131
+ `the ledger contradicts this session's observations: ${describeDegraded(contradicted).join(" ")}`,
132
+ "re-run the verification command so a fresh, observed result replaces the contradicted record",
133
+ );
134
+ }
135
+
136
+ if (runs.length === 0 && degraded.length > 0) {
137
+ // Resuming in a new process lands here every time, by design: evidence
138
+ // means "observed by this kernel", and a fresh process has observed
139
+ // nothing yet. Correct, but a bare "insufficient evidence" would read as a
140
+ // bug and get the gate switched off, so the refusal says what happened and
141
+ // what to do about it.
142
+ return deny(
143
+ `the ledger holds ${degraded.length} record(s) from a previous session, which this kernel did not observe, so they are unverified and cannot support a checkoff`,
144
+ "re-run the verification command in this session so the result is observed, then check the task off",
145
+ );
146
+ }
147
+
148
+ if (runs.length === 0) {
149
+ return deny(`no command has been observed running for ${request.task}, so there is nothing to record as evidence`);
150
+ }
151
+
152
+ // The declared check, before success is even considered: a green `echo` is
153
+ // not a failing test run, it is not a test run at all.
154
+ const relevant = request.runner === null
155
+ ? runs
156
+ : runs.filter((run) => isDeclaredRunner(run.command, request.runner!));
157
+ if (relevant.length === 0) {
158
+ const observed = runs.map((run) => `\`${run.command}\``).join(", ");
159
+ return deny(
160
+ `this feature declared \`${request.runner}\` as its verification runner, and no observed command was a run of it (observed: ${observed})`,
161
+ `run \`${request.runner}\`, then check ${request.task} off once it is observed to succeed`,
162
+ );
163
+ }
164
+
165
+ const green = relevant.filter((run) => isSuccess(run.outcome));
166
+ if (green.length === 0) {
167
+ const last = relevant[relevant.length - 1];
168
+ return deny(`the last observed run of \`${last.command}\` ended ${describeOutcome(last.outcome)}, which is not success`);
169
+ }
170
+
171
+ const afterWrite = green.filter((run) => run.seq > (request.lastWriteSeq ?? 0) && run.at >= request.lastWriteAt);
172
+ if (afterWrite.length === 0) {
173
+ const last = green[green.length - 1];
174
+ return deny(
175
+ `the only observed success (\`${last.command}\`) ran before the last write to ${request.task}, so it says nothing about the current code`,
176
+ );
177
+ }
178
+
179
+ if (request.tdd?.mode === "strict") {
180
+ const chosen = afterWrite[afterWrite.length - 1];
181
+ const red = relevant.find((run) => !isSuccess(run.outcome) && run.at <= chosen.at);
182
+ if (!red) {
183
+ return deny(
184
+ `TDD mode is strict (${request.tdd.source}) but no failing (RED) run was observed before this success, so the test was never seen to fail first`,
185
+ );
186
+ }
187
+ }
188
+
189
+ const chosen = afterWrite[afterWrite.length - 1];
190
+ return {
191
+ allow: true,
192
+ observed: {
193
+ command: chosen.command,
194
+ outcome: request.runner === null
195
+ ? `${describeOutcome(chosen.outcome)} (runner not pinned)`
196
+ : describeOutcome(chosen.outcome),
197
+ at: chosen.at,
198
+ toolCallId: chosen.toolCallId,
199
+ ...(request.tdd?.mode === "strict" ? { tdd: request.tdd } : {}),
200
+ },
201
+ };
202
+ }
203
+
204
+ /** The feature doc's evidence line. Never rendered from anything but a decision. */
205
+ export function renderObserved(observed: ObservedEvidence | null): string {
206
+ if (!observed) return "observed: none";
207
+ if (observed.outcome === "none (gate disabled)") return "observed: none (gate disabled)";
208
+ return `observed: \`${observed.command}\` → ${observed.outcome}`;
209
+ }
@@ -0,0 +1,77 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { GATE_IDS } from "./registry.ts";
4
+ import {
5
+ allow,
6
+ consumeHatch,
7
+ firstRefusal,
8
+ grantHatch,
9
+ refuse,
10
+ resolveFlag,
11
+ type Policy,
12
+ } from "./policy.ts";
13
+
14
+ function policy(over: Partial<Policy> = {}): Policy {
15
+ return { config: {}, flags: {}, hatches: {}, ...over };
16
+ }
17
+
18
+ test("every gate id has a default flag entry and defaults to on", () => {
19
+ for (const id of GATE_IDS) {
20
+ const resolved = resolveFlag(id, policy());
21
+ assert.equal(resolved.enabled, true, `${id} must default to enabled`);
22
+ assert.equal(resolved.source, "default", `${id} with nothing chosen must report source default`);
23
+ }
24
+ });
25
+
26
+ test("precedence is flag > config > default, and the source is retained", () => {
27
+ assert.deepEqual(resolveFlag("track", policy()), { enabled: true, source: "default" });
28
+ assert.deepEqual(
29
+ resolveFlag("track", policy({ config: { track: { enabled: false } } })),
30
+ { enabled: false, source: "config" },
31
+ );
32
+ assert.deepEqual(
33
+ resolveFlag("track", policy({ config: { track: { enabled: true } }, flags: { track: false } })),
34
+ { enabled: false, source: "flag" },
35
+ );
36
+ assert.deepEqual(
37
+ resolveFlag("track", policy({ flags: { all: false } })),
38
+ { enabled: false, source: "flag" },
39
+ );
40
+ });
41
+
42
+ test("a refusal carries a remedy with a concrete action and the escape hatch", () => {
43
+ const decision = refuse("track", "no feature doc at .nodd/x/feature.md", "call nodd_declare");
44
+ assert.equal(decision.allow, false);
45
+ assert.equal(decision.gate, "track");
46
+ assert.equal(decision.remedy.action, "call nodd_declare");
47
+ assert.equal(decision.remedy.escapeHatch, "/nodd-allow track");
48
+ });
49
+
50
+ test("every gate's refusal renders gate id, observation and both remedies", () => {
51
+ for (const id of GATE_IDS) {
52
+ const decision = refuse(id, `observed something for ${id}`, `do the ${id} thing`);
53
+ assert.equal(decision.allow, false);
54
+ assert.ok(decision.reason.includes(id), `${id}: reason names the gate`);
55
+ assert.ok(decision.reason.includes("observed something"), `${id}: reason names the observation`);
56
+ assert.ok(decision.reason.includes(`do the ${id} thing`), `${id}: reason names the action`);
57
+ assert.ok(decision.reason.includes(`/nodd-allow ${id}`), `${id}: reason names the escape hatch`);
58
+ }
59
+ });
60
+
61
+ test("the first refusal wins, so a call never gets two messages", () => {
62
+ const decisions = [allow(), refuse("classify", "a", "b"), refuse("track", "c", "d")];
63
+ const winner = firstRefusal(decisions);
64
+ assert.equal(winner?.gate, "classify");
65
+ assert.equal(firstRefusal([allow(), allow()]), null);
66
+ });
67
+
68
+ test("an escape hatch is one-shot and never crosses gates", () => {
69
+ let p = grantHatch(policy(), "track", "I know what I am doing");
70
+ assert.equal(consumeHatch(p, "classify"), null, "an override for track must not clear classify");
71
+
72
+ const consumed = consumeHatch(p, "track");
73
+ assert.ok(consumed);
74
+ assert.equal(consumed.reason, "I know what I am doing");
75
+ p = consumed.policy;
76
+ assert.equal(consumeHatch(p, "track"), null, "the override is consumed, so the next refusal blocks");
77
+ });
@@ -0,0 +1,90 @@
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
+
11
+ import type { GateId } from "./registry.ts";
12
+
13
+ export type Remedy = {
14
+ action: string;
15
+ escapeHatch: `/nodd-allow ${GateId}`;
16
+ };
17
+
18
+ export type GateDecision =
19
+ | { allow: true }
20
+ | { allow: false; gate: GateId; reason: string; remedy: Remedy };
21
+
22
+ export type FlagSource = "flag" | "config" | "default";
23
+ export type ResolvedFlag = { enabled: boolean; source: FlagSource };
24
+
25
+ export 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
+
34
+ export function emptyPolicy(): Policy {
35
+ return { config: {}, flags: {}, hatches: {} };
36
+ }
37
+
38
+ export 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
+
49
+ export 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
+ */
57
+ export 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
+ /** First refusal wins: a single call never collects two overlapping messages. */
68
+ export function firstRefusal(decisions: GateDecision[]): Extract<GateDecision, { allow: false }> | null {
69
+ for (const decision of decisions) {
70
+ if (decision.allow === false) return decision;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ export function grantHatch(policy: Policy, gate: GateId, reason: string, at?: string): Policy {
76
+ return { ...policy, hatches: { ...policy.hatches, [gate]: { reason, ...(at ? { at } : {}) } } };
77
+ }
78
+
79
+ /**
80
+ * Consume a one-shot override for exactly this gate. Returns the reason and the
81
+ * policy with the override spent, or null when there is none — never implicit,
82
+ * never permanent, never cross-gate.
83
+ */
84
+ export function consumeHatch(policy: Policy, gate: GateId): { reason: string; policy: Policy } | null {
85
+ const hatch = policy.hatches[gate];
86
+ if (!hatch) return null;
87
+ const hatches = { ...policy.hatches };
88
+ delete hatches[gate];
89
+ return { reason: hatch.reason, policy: { ...policy, hatches } };
90
+ }
@@ -0,0 +1,133 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { emptyPolicy } from "./policy.ts";
5
+ import { promotionGate, type PromotionSignals } from "./promotion.ts";
6
+
7
+ const noSignals: PromotionSignals = {
8
+ slug: "cache-warmup",
9
+ consecutiveFailures: 0,
10
+ failedTaskId: null,
11
+ declaredFiles: 0,
12
+ observedFiles: 0,
13
+ };
14
+
15
+ const request = { toolName: "write", input: { file_path: "/repo/src/cache.ts" } };
16
+
17
+ function decide(signals: Partial<PromotionSignals>) {
18
+ return promotionGate({ ...noSignals, ...signals }, request, emptyPolicy());
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Trigger 1 — two consecutive non-success outcomes on one task
23
+ // ---------------------------------------------------------------------------
24
+ test("two consecutive failures on one task block, naming the task and both attempts", () => {
25
+ const decision = decide({ consecutiveFailures: 2, failedTaskId: "T007" });
26
+ assert.equal(decision.allow, false);
27
+ if (decision.allow) return;
28
+ assert.ok(decision.reason.includes("T007"), "the blocked task is named");
29
+ assert.match(decision.reason, /2|dos/, "both attempts are counted");
30
+ });
31
+
32
+ test("one failure does not block: a first failure is normal work", () => {
33
+ assert.equal(decide({ consecutiveFailures: 1, failedTaskId: "T007" }).allow, true);
34
+ });
35
+
36
+ test("failure then success does not block: the streak is consecutive, not cumulative", () => {
37
+ // The caller resets the counter on success; the gate must not carry history
38
+ // of its own that could keep a resolved task blocked forever.
39
+ assert.equal(decide({ consecutiveFailures: 0, failedTaskId: "T007" }).allow, true);
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Trigger 2 — mismatch between declared and observed files
44
+ // ---------------------------------------------------------------------------
45
+ test("3 files observed against 2 declared blocks with both counts", () => {
46
+ const decision = decide({ declaredFiles: 2, observedFiles: 3 });
47
+ assert.equal(decision.allow, false);
48
+ if (decision.allow) return;
49
+ assert.ok(decision.reason.includes("3") && decision.reason.includes("2"), decision.reason);
50
+ });
51
+
52
+ test("40 observed against 40 declared does not block: mismatch, not magnitude", () => {
53
+ assert.equal(decide({ declaredFiles: 40, observedFiles: 40 }).allow, true);
54
+ });
55
+
56
+ test("fewer files than declared does not block: doing less than planned is not divergence upward", () => {
57
+ assert.equal(decide({ declaredFiles: 9, observedFiles: 2 }).allow, true);
58
+ });
59
+
60
+ test("zero declared is not a mismatch: nothing was promised to diverge from", () => {
61
+ assert.equal(decide({ declaredFiles: 0, observedFiles: 12 }).allow, true);
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // The trigger that was specified and then removed
66
+ // ---------------------------------------------------------------------------
67
+ test("there is no user-request trigger: it was not derivable and is gone, not faked", () => {
68
+ // `/nodd-promote` lives in another extension with no path to kernel state and
69
+ // performs the promotion itself, so no gate could ever observe the asking.
70
+ // Round 1 shipped it as a hardcoded `false`, which is the exact failure this
71
+ // gate exists to refuse. It is absent from the type and from the source.
72
+ assert.ok(!("userRequested" in noSignals), "the signal is gone from the type");
73
+ const source = readFileSync(new URL("./promotion.ts", import.meta.url), "utf8")
74
+ .split("\n")
75
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
76
+ .join("\n");
77
+ assert.ok(!source.includes("userRequested"), "and gone from the executable code");
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // The two ways forward
82
+ // ---------------------------------------------------------------------------
83
+ test("a block offers exactly the promote command and the escape hatch", () => {
84
+ const decision = decide({ declaredFiles: 2, observedFiles: 3 });
85
+ assert.equal(decision.allow, false);
86
+ if (decision.allow) return;
87
+ assert.ok(decision.remedy.action.includes("/nodd-promote cache-warmup"), decision.remedy.action);
88
+ assert.equal(decision.remedy.escapeHatch, "/nodd-allow promotion");
89
+ });
90
+
91
+ test("no trigger at all allows", () => {
92
+ assert.equal(decide({}).allow, true);
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Kill switch
97
+ // ---------------------------------------------------------------------------
98
+ test("disabling the gate turns it off entirely, even with every trigger firing", () => {
99
+ const policy = { ...emptyPolicy(), config: { promotion: { enabled: false } } };
100
+ const decision = promotionGate(
101
+ { slug: "x", consecutiveFailures: 5, failedTaskId: "npm test", declaredFiles: 1, observedFiles: 99 },
102
+ request,
103
+ policy,
104
+ );
105
+ assert.equal(decision.allow, true, "a user-owned kill switch is not advisory");
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // The part of routing.go:68 NODD keeps
110
+ // ---------------------------------------------------------------------------
111
+ test("no promotion path reads a line count, a byte size or a risk score", () => {
112
+ const source = readFileSync(new URL("./promotion.ts", import.meta.url), "utf8")
113
+ .split("\n")
114
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
115
+ .join("\n");
116
+
117
+ for (const forbidden of ["lineCount", "linesChanged", "byteSize", "risk", "complexity", "estimate", "forecast"]) {
118
+ assert.ok(!source.includes(forbidden), `promotion must not read ${forbidden}`);
119
+ }
120
+ assert.ok(!source.includes("countAuthoredLines"), "the delivery counter is off limits to gates");
121
+ assert.ok(!/from "\.\.\/delivery\.ts"/.test(source), "promotion must not import delivery");
122
+ });
123
+
124
+ test("the signals type exposes no size field at all", () => {
125
+ const signals: PromotionSignals = { ...noSignals };
126
+ assert.deepEqual(Object.keys(signals).sort(), [
127
+ "consecutiveFailures",
128
+ "declaredFiles",
129
+ "failedTaskId",
130
+ "observedFiles",
131
+ "slug",
132
+ ]);
133
+ });
@@ -0,0 +1,81 @@
1
+ // gate-promotion — escalate on divergence, never on size.
2
+ //
3
+ // `REQ: escalation-divergence`. ODD escalates a route by predicted magnitude
4
+ // (`routing.go:68`); NODD keeps only the half of that decision it can observe.
5
+ // The two triggers are:
6
+ //
7
+ // 1. two consecutive non-`success` outcomes of the declared runner — the plan
8
+ // is not working, which is a fact about observed results;
9
+ // 2. a task writing more distinct files than it declared — the work is not
10
+ // the shape it was declared to be;
11
+ //
12
+ // ## The trigger that was removed
13
+ //
14
+ // A third trigger, "the user asked", was specified and then deleted, because it
15
+ // was not derivable. `/nodd-promote` lives in another extension with no path to
16
+ // kernel state, and it *performs* the promotion rather than requesting one —
17
+ // there is no moment at which a gate could observe the asking and still have
18
+ // something left to do about it. Round 1 shipped it as a hardcoded `false`,
19
+ // which is the failure this whole gate exists to refuse: a fabricated signal is
20
+ // worse than an absent one, so the condition is gone from the type, the gate,
21
+ // the requirement and the README rather than left there looking operational.
22
+ //
23
+ // **No size, line count, byte count or risk score is read.** A big task that
24
+ // succeeds is not divergent, and a three-line task that fails twice is. The
25
+ // counts this gate does use are *declared vs observed*, which is a mismatch, not
26
+ // a magnitude: 40 files against 40 declared allows, 3 against 2 blocks. A test
27
+ // scans this file for every size-flavoured identifier, including any import of
28
+ // `delivery.ts`, whose line counter exists to inform a human and must never
29
+ // reach a gate.
30
+ //
31
+ // A block offers exactly two ways forward, because a gate that stops the work
32
+ // without naming the exit is a dead end.
33
+
34
+ import { allow, refuse, resolveFlag, type GateDecision, type Policy } from "./policy.ts";
35
+ import type { GateRequest } from "./request.ts";
36
+ import { isFileWrite } from "./request.ts";
37
+
38
+ export type PromotionSignals = {
39
+ slug: string;
40
+ /** Trailing non-`success` outcomes of the declared runner, derived by the caller. */
41
+ consecutiveFailures: number;
42
+ /** The command whose runs failed, quoted in the refusal. */
43
+ failedTaskId: string | null;
44
+ /** Distinct files the current task said it would touch. */
45
+ declaredFiles: number;
46
+ /** Distinct files it has actually written. */
47
+ observedFiles: number;
48
+ };
49
+
50
+ const CONSECUTIVE_FAILURE_LIMIT = 2;
51
+
52
+ export function promotionGate(signals: PromotionSignals, request: GateRequest, policy: Policy): GateDecision {
53
+ if (!resolveFlag("promotion", policy).enabled) return allow();
54
+ if (!isFileWrite(request)) return allow();
55
+
56
+ const action =
57
+ `promote the run with \`/nodd-promote ${signals.slug}\` so forge plans the rest, ` +
58
+ "or keep going here if you judge the divergence is not real";
59
+
60
+ if (signals.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
61
+ return refuse(
62
+ "promotion",
63
+ `task ${signals.failedTaskId ?? "(unnamed)"} has failed ${signals.consecutiveFailures} times in a row: ` +
64
+ "the plan is not working, which is what escalation is for",
65
+ action,
66
+ );
67
+ }
68
+
69
+ // Zero declared means nothing was promised, so there is nothing to diverge
70
+ // from — that is an undeclared task, which is `gate-track`'s business.
71
+ if (signals.declaredFiles > 0 && signals.observedFiles > signals.declaredFiles) {
72
+ return refuse(
73
+ "promotion",
74
+ `this task declared ${signals.declaredFiles} file(s) and has written ${signals.observedFiles}: ` +
75
+ "the work is not the shape it was declared to be",
76
+ action,
77
+ );
78
+ }
79
+
80
+ return allow();
81
+ }
@@ -0,0 +1,21 @@
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
+
8
+ export const GATE_IDS = Object.freeze([
9
+ "authorize",
10
+ "classify",
11
+ "track",
12
+ "delegate",
13
+ "evidence",
14
+ "promotion",
15
+ ] as const);
16
+
17
+ export type GateId = (typeof GATE_IDS)[number];
18
+
19
+ export function isGateId(value: string): value is GateId {
20
+ return (GATE_IDS as readonly string[]).includes(value);
21
+ }
@@ -0,0 +1,41 @@
1
+ // The shape a gate sees: one tool call, before it runs.
2
+ //
3
+ // This is the extension's translation of pi's `ToolCallEvent`
4
+ // (`types.d.ts:649-691`) into something pure. Gates never touch pi types.
5
+
6
+ import { classifyBash } from "../bash-classifier.ts";
7
+
8
+ export type GateRequest = {
9
+ toolName: string;
10
+ input: Record<string, unknown>;
11
+ };
12
+
13
+ export function targetPath(request: GateRequest): string | null {
14
+ const path = request.input?.path;
15
+ return typeof path === "string" && path !== "" ? path : null;
16
+ }
17
+
18
+ /** A direct, typed file write: `write` or `edit`. Exactly gateable. */
19
+ export function isFileWrite(request: GateRequest): boolean {
20
+ return request.toolName === "write" || request.toolName === "edit";
21
+ }
22
+
23
+ /** A bash call whose command matches the documented mutation denylist. */
24
+ export function isMutatingBash(request: GateRequest): boolean {
25
+ if (request.toolName !== "bash") return false;
26
+ const command = request.input?.command;
27
+ return typeof command === "string" && classifyBash(command) === "mutating";
28
+ }
29
+
30
+ /**
31
+ * Any observable attempt to change the workspace: a typed write, or a bash
32
+ * command the classifier recognises as mutating.
33
+ */
34
+ export function isMutation(request: GateRequest): boolean {
35
+ return isFileWrite(request) || isMutatingBash(request);
36
+ }
37
+
38
+ /** Delegating to a writer is a mutation by proxy (`routing.go:44`). */
39
+ export function isDelegation(request: GateRequest): boolean {
40
+ return request.toolName === "subagent";
41
+ }