@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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/extensions/nodd-agents.test.ts +129 -0
- package/extensions/nodd-agents.ts +185 -0
- package/extensions/nodd-allow.test.ts +75 -0
- package/extensions/nodd-allow.ts +76 -0
- package/extensions/nodd-enforcement.test.ts +676 -0
- package/extensions/nodd-gates.test.ts +108 -0
- package/extensions/nodd-gates.ts +121 -0
- package/extensions/nodd-kernel.test.ts +114 -0
- package/extensions/nodd-kernel.ts +593 -0
- package/extensions/nodd-models.test.ts +174 -0
- package/extensions/nodd-models.ts +253 -0
- package/extensions/nodd-promote.test.ts +150 -0
- package/extensions/nodd-promote.ts +96 -0
- package/extensions/nodd-prompt.test.ts +87 -0
- package/extensions/nodd-tools.test.ts +211 -0
- package/package.json +44 -0
- package/src/bash-classifier.test.ts +114 -0
- package/src/bash-classifier.ts +69 -0
- package/src/change-acceptance.test.ts +175 -0
- package/src/change-acceptance.ts +98 -0
- package/src/config.test.ts +61 -0
- package/src/config.ts +103 -0
- package/src/delivery.test.ts +156 -0
- package/src/delivery.ts +151 -0
- package/src/feature-doc.test.ts +120 -0
- package/src/feature-doc.ts +292 -0
- package/src/gates/authorize.test.ts +62 -0
- package/src/gates/authorize.ts +32 -0
- package/src/gates/classify.test.ts +54 -0
- package/src/gates/classify.ts +45 -0
- package/src/gates/delegate.test.ts +127 -0
- package/src/gates/delegate.ts +85 -0
- package/src/gates/evidence.test.ts +281 -0
- package/src/gates/evidence.ts +209 -0
- package/src/gates/policy.test.ts +77 -0
- package/src/gates/policy.ts +90 -0
- package/src/gates/promotion.test.ts +133 -0
- package/src/gates/promotion.ts +81 -0
- package/src/gates/registry.ts +21 -0
- package/src/gates/request.ts +41 -0
- package/src/gates/track.test.ts +80 -0
- package/src/gates/track.ts +58 -0
- package/src/io.test.ts +81 -0
- package/src/io.ts +94 -0
- package/src/ledger.test.ts +122 -0
- package/src/ledger.ts +133 -0
- package/src/manifest.test.ts +53 -0
- package/src/manifest.ts +61 -0
- package/src/models/assign.test.ts +125 -0
- package/src/models/assign.ts +138 -0
- package/src/models/picker.test.ts +141 -0
- package/src/models/picker.ts +98 -0
- package/src/models/profiles.test.ts +186 -0
- package/src/models/profiles.ts +162 -0
- package/src/models/slots.ts +48 -0
- package/src/observations.test.ts +61 -0
- package/src/observations.ts +51 -0
- package/src/odd-prose.test.ts +125 -0
- package/src/odd-prose.ts +198 -0
- package/src/outcome.test.ts +75 -0
- package/src/outcome.ts +63 -0
- package/src/promote.test.ts +129 -0
- package/src/promote.ts +64 -0
- package/src/prompt.test.ts +193 -0
- package/src/prompt.ts +136 -0
- package/src/review-candidate.test.ts +118 -0
- package/src/review-candidate.ts +81 -0
- package/src/state.test.ts +153 -0
- package/src/state.ts +163 -0
- package/test/package-invariants.test.ts +66 -0
- package/test/parity-matrix.test.ts +272 -0
- 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
|
+
}
|