@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,87 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { BLOCK_A_BUDGET, BLOCK_B_BUDGET } from "../src/prompt.ts";
|
|
7
|
+
import register from "./nodd-kernel.ts";
|
|
8
|
+
|
|
9
|
+
type Handler = (event: unknown) => unknown;
|
|
10
|
+
|
|
11
|
+
function host() {
|
|
12
|
+
const handlers = new Map<string, Handler>();
|
|
13
|
+
const pi = {
|
|
14
|
+
on: (event: string, handler: Handler) => handlers.set(event, handler),
|
|
15
|
+
registerTool: () => {},
|
|
16
|
+
appendEntry: () => {},
|
|
17
|
+
};
|
|
18
|
+
const kernel = register(pi as never, mkdtempSync(join(tmpdir(), "nodd-prompt-")));
|
|
19
|
+
return { handlers, kernel };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fire(incoming: string | null | undefined): string {
|
|
23
|
+
const { handlers } = host();
|
|
24
|
+
const handler = handlers.get("before_agent_start");
|
|
25
|
+
assert.ok(handler, "the kernel must register a before_agent_start handler");
|
|
26
|
+
const result = handler!({ systemPrompt: incoming }) as { systemPrompt?: string } | undefined;
|
|
27
|
+
return result?.systemPrompt ?? "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Chained, never replaced
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
test("the handler is registered on before_agent_start", () => {
|
|
34
|
+
assert.ok(host().handlers.has("before_agent_start"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the result starts with the incoming prompt, byte for byte", () => {
|
|
38
|
+
const incoming = "ANOTHER EXTENSION'S CONTRIBUTION\nwith two lines.";
|
|
39
|
+
const result = fire(incoming);
|
|
40
|
+
assert.ok(result.startsWith(incoming), `other extensions' contributions must survive:\n${result.slice(0, 120)}`);
|
|
41
|
+
assert.ok(result.length > incoming.length, "NODD appends its own blocks");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("both blocks are appended", () => {
|
|
45
|
+
const result = fire("base");
|
|
46
|
+
assert.match(result, /NODD/, "block A is present");
|
|
47
|
+
assert.match(result, /ODD · guía|gates disabled/, "block B or the disabled line is present");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an empty incoming prompt still yields NODD's blocks", () => {
|
|
51
|
+
const result = fire("");
|
|
52
|
+
assert.match(result, /NODD/);
|
|
53
|
+
assert.ok(result.length > 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a null or missing incoming prompt drops no content", () => {
|
|
57
|
+
for (const incoming of [null, undefined]) {
|
|
58
|
+
const result = fire(incoming);
|
|
59
|
+
assert.match(result, /NODD/, `incoming=${incoming} must still produce NODD's blocks`);
|
|
60
|
+
assert.ok(!result.includes("null") && !result.includes("undefined"), "no stringified nullish leaks in");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the appended content respects the budgets", () => {
|
|
65
|
+
const incoming = "base prompt";
|
|
66
|
+
const appended = fire(incoming).slice(incoming.length);
|
|
67
|
+
assert.ok(
|
|
68
|
+
appended.length <= BLOCK_A_BUDGET + BLOCK_B_BUDGET + 8,
|
|
69
|
+
`appended ${appended.length} exceeds the two budgets plus separators`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("firing twice does not accumulate: the prompt is rebuilt, not appended to itself", () => {
|
|
74
|
+
const { handlers } = host();
|
|
75
|
+
const handler = handlers.get("before_agent_start")!;
|
|
76
|
+
const first = (handler({ systemPrompt: "base" }) as { systemPrompt: string }).systemPrompt;
|
|
77
|
+
const second = (handler({ systemPrompt: "base" }) as { systemPrompt: string }).systemPrompt;
|
|
78
|
+
assert.equal(first, second, "a second turn with the same state renders the same prompt");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the handler never throws, whatever the event shape", () => {
|
|
82
|
+
const { handlers } = host();
|
|
83
|
+
const handler = handlers.get("before_agent_start")!;
|
|
84
|
+
for (const event of [undefined, null, {}, { systemPrompt: 42 }, "nonsense"]) {
|
|
85
|
+
assert.doesNotThrow(() => handler(event as never), `event ${JSON.stringify(event)} must not throw`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import register, { createKernel, featureDocPath } from "./nodd-kernel.ts";
|
|
7
|
+
import { parseFeatureDoc } from "../src/feature-doc.ts";
|
|
8
|
+
|
|
9
|
+
function fakePi() {
|
|
10
|
+
const tools = new Map<string, any>();
|
|
11
|
+
return {
|
|
12
|
+
tools,
|
|
13
|
+
on() {},
|
|
14
|
+
appendEntry() {},
|
|
15
|
+
registerTool(name: string, options: any) { tools.set(name, options); },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tmp(): string {
|
|
20
|
+
return mkdtempSync(join(tmpdir(), "nodd-tools-"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("both tools are registered with pi", () => {
|
|
24
|
+
const pi = fakePi();
|
|
25
|
+
register(pi as never);
|
|
26
|
+
assert.ok(pi.tools.has("nodd_declare"), "nodd_declare must be registered");
|
|
27
|
+
assert.ok(pi.tools.has("nodd_task"), "nodd_task must be registered");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("route: tracked creates the feature doc and reports it in one line", () => {
|
|
31
|
+
const cwd = tmp();
|
|
32
|
+
const kernel = createKernel(undefined, cwd);
|
|
33
|
+
const result = kernel.declare({
|
|
34
|
+
intent: "change", route: "tracked", slug: "demo",
|
|
35
|
+
summary: "Make the thing work", title: "Demo feature",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const path = featureDocPath(cwd, "demo");
|
|
39
|
+
assert.ok(existsSync(path), `${path} must exist`);
|
|
40
|
+
const parsed = parseFeatureDoc(readFileSync(path, "utf8"));
|
|
41
|
+
assert.deepEqual(parsed.defects, []);
|
|
42
|
+
assert.equal(parsed.doc.objective, "Make the thing work");
|
|
43
|
+
assert.deepEqual(parsed.doc.tasks, [], "a fresh doc has an empty Tasks section");
|
|
44
|
+
assert.equal(result.text, ".nodd/demo/feature.md created with 0 tasks");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A runner is pinned once. `tdd: strict` was not: re-declaring while omitting
|
|
48
|
+
// `tdd` rewrote the document to `- tdd: off`, with no refusal and no trace, and
|
|
49
|
+
// a GREEN with no RED then checked the task off. Dropping a discipline is as
|
|
50
|
+
// much a change to how the work is judged as swapping the runner is.
|
|
51
|
+
test("strict TDD cannot be dropped by re-declaring without it", () => {
|
|
52
|
+
const cwd = tmp();
|
|
53
|
+
const kernel = createKernel(undefined, cwd);
|
|
54
|
+
kernel.declare({
|
|
55
|
+
intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo",
|
|
56
|
+
runner: "npm test", tdd: "strict",
|
|
57
|
+
} as never);
|
|
58
|
+
|
|
59
|
+
const second = kernel.declare({
|
|
60
|
+
intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo",
|
|
61
|
+
runner: "npm test",
|
|
62
|
+
} as never);
|
|
63
|
+
|
|
64
|
+
assert.equal(second.ok, false, "omitting tdd must not silently downgrade strict to off");
|
|
65
|
+
const parsed = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8"));
|
|
66
|
+
assert.equal(parsed.doc.verification.tdd, "strict", "the pinned discipline must survive the refused re-declaration");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("route: inline creates no durable artifact", () => {
|
|
70
|
+
const cwd = tmp();
|
|
71
|
+
const kernel = createKernel(undefined, cwd);
|
|
72
|
+
kernel.declare({ intent: "change", route: "inline", slug: "small", summary: "one-liner", title: "Small" });
|
|
73
|
+
assert.equal(existsSync(featureDocPath(cwd, "small")), false, "small understood work stays small");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("invalid enum values are rejected before any file write", () => {
|
|
77
|
+
const cwd = tmp();
|
|
78
|
+
const pi = fakePi();
|
|
79
|
+
const kernel = register(pi as never, cwd);
|
|
80
|
+
const declare = pi.tools.get("nodd_declare")!;
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(declare.parameters.properties.route.enum, ["inline", "tracked", "forge"]);
|
|
83
|
+
assert.deepEqual(declare.parameters.properties.intent.enum, ["read-only", "change"]);
|
|
84
|
+
|
|
85
|
+
const result = kernel.declare({ intent: "change", route: "sideways" as never, slug: "x", summary: "s", title: "T" });
|
|
86
|
+
assert.equal(result.ok, false);
|
|
87
|
+
assert.match(result.text, /route/);
|
|
88
|
+
assert.equal(existsSync(featureDocPath(cwd, "x")), false, "a rejected declaration writes nothing");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("nodd_task add appends a task and the count is reported from the doc", () => {
|
|
92
|
+
const cwd = tmp();
|
|
93
|
+
const kernel = createKernel(undefined, cwd);
|
|
94
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
95
|
+
|
|
96
|
+
const added = kernel.task({ action: "add", id: "T1", title: "First task", slug: "demo" });
|
|
97
|
+
assert.equal(added.ok, true);
|
|
98
|
+
const parsed = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8"));
|
|
99
|
+
assert.deepEqual(parsed.doc.tasks, [{ id: "T1", title: "First task", checked: false }]);
|
|
100
|
+
|
|
101
|
+
const second = kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
102
|
+
assert.match(second.text, /with 1 tasks?$/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the model never writes the doc: the extension does", () => {
|
|
106
|
+
const cwd = tmp();
|
|
107
|
+
const kernel = createKernel(undefined, cwd);
|
|
108
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
109
|
+
const before = readFileSync(featureDocPath(cwd, "demo"), "utf8");
|
|
110
|
+
// There is no API that accepts doc text from the caller.
|
|
111
|
+
assert.ok(!("writeDoc" in kernel), "the kernel exposes no raw doc writer");
|
|
112
|
+
assert.equal(readFileSync(featureDocPath(cwd, "demo"), "utf8"), before);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Observe a successful bash run, so the evidence gate has something to see. */
|
|
116
|
+
function observeGreen(kernel: ReturnType<typeof createKernel>, command: string, id = "v1"): void {
|
|
117
|
+
kernel.onToolResult({ toolCallId: id, toolName: "bash", input: { command }, isError: false, content: "ok" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test("a checkoff with no observed run is refused by the evidence gate", () => {
|
|
121
|
+
const cwd = tmp();
|
|
122
|
+
const kernel = createKernel(undefined, cwd);
|
|
123
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
124
|
+
kernel.task({ action: "add", id: "T1", title: "First", slug: "demo" });
|
|
125
|
+
|
|
126
|
+
const checked = kernel.task({ action: "check", id: "T1", slug: "demo" });
|
|
127
|
+
assert.equal(checked.ok, false, "nothing was observed, so nothing may be claimed");
|
|
128
|
+
assert.match(checked.text, /nodd\/evidence/);
|
|
129
|
+
assert.equal(
|
|
130
|
+
parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc.tasks[0].checked,
|
|
131
|
+
false,
|
|
132
|
+
"a refused checkoff changes nothing on disk",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a checkoff records the observed command, never an invented one", () => {
|
|
137
|
+
const cwd = tmp();
|
|
138
|
+
const kernel = createKernel(undefined, cwd);
|
|
139
|
+
// The runner is pinned here because that is the ordinary tracked declaration:
|
|
140
|
+
// an unpinned one records `success (runner not pinned)` instead, which is a
|
|
141
|
+
// different claim and has its own tests.
|
|
142
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo", runner: "node --test" });
|
|
143
|
+
kernel.task({ action: "add", id: "T1", title: "First", slug: "demo" });
|
|
144
|
+
observeGreen(kernel, "node --test");
|
|
145
|
+
|
|
146
|
+
assert.equal(kernel.task({ action: "check", id: "T1", slug: "demo" }).ok, true);
|
|
147
|
+
const task = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc.tasks[0];
|
|
148
|
+
assert.equal(task.checked && task.evidence.command, "node --test");
|
|
149
|
+
assert.equal(task.checked && task.evidence.outcome, "success");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a checkoff with no observed commit records pending-commit", () => {
|
|
153
|
+
const cwd = tmp();
|
|
154
|
+
const kernel = createKernel(undefined, cwd);
|
|
155
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
156
|
+
kernel.task({ action: "add", id: "T1", title: "First", slug: "demo" });
|
|
157
|
+
observeGreen(kernel, "node --test");
|
|
158
|
+
kernel.task({ action: "check", id: "T1", slug: "demo" });
|
|
159
|
+
|
|
160
|
+
const task = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc.tasks[0];
|
|
161
|
+
assert.equal(task.checked && task.candidate, "pending-commit");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("nodd_task reopen without a reason is refused; with one it records under Progress", () => {
|
|
165
|
+
const cwd = tmp();
|
|
166
|
+
const kernel = createKernel(undefined, cwd);
|
|
167
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
168
|
+
kernel.task({ action: "add", id: "T1", title: "First", slug: "demo" });
|
|
169
|
+
observeGreen(kernel, "node --test");
|
|
170
|
+
kernel.task({ action: "check", id: "T1", slug: "demo" });
|
|
171
|
+
|
|
172
|
+
const bare = kernel.task({ action: "reopen", id: "T1", slug: "demo" });
|
|
173
|
+
assert.equal(bare.ok, false);
|
|
174
|
+
assert.match(bare.text, /reason/i);
|
|
175
|
+
assert.equal(
|
|
176
|
+
parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc.tasks[0].checked,
|
|
177
|
+
true,
|
|
178
|
+
"a refused reopen changes nothing on disk",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const withReason = kernel.task({ action: "reopen", id: "T1", slug: "demo", reason: "the check ran against stale code" });
|
|
182
|
+
assert.equal(withReason.ok, true);
|
|
183
|
+
const doc = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc;
|
|
184
|
+
assert.equal(doc.tasks[0].checked, false);
|
|
185
|
+
assert.match(doc.progress, /T1 reopened: the check ran against stale code/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the reopen action is in the tool schema so the model can name it", () => {
|
|
189
|
+
const pi = fakePi();
|
|
190
|
+
register(pi as never, tmp());
|
|
191
|
+
assert.deepEqual(pi.tools.get("nodd_task")!.parameters.properties.action.enum, ["add", "check", "reopen"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a checkoff after an observed commit records that SHA as the candidate", () => {
|
|
195
|
+
const cwd = tmp();
|
|
196
|
+
const kernel = createKernel(undefined, cwd);
|
|
197
|
+
kernel.declare({ intent: "change", route: "tracked", slug: "demo", summary: "obj", title: "Demo" });
|
|
198
|
+
kernel.task({ action: "add", id: "T1", title: "First", slug: "demo" });
|
|
199
|
+
|
|
200
|
+
kernel.onToolResult({
|
|
201
|
+
toolCallId: "g1",
|
|
202
|
+
toolName: "bash",
|
|
203
|
+
input: { command: "git commit -m 'feat: first'" },
|
|
204
|
+
isError: false,
|
|
205
|
+
content: "[main 7c0ffee] feat: first\n 1 file changed",
|
|
206
|
+
});
|
|
207
|
+
kernel.task({ action: "check", id: "T1", slug: "demo" });
|
|
208
|
+
|
|
209
|
+
const task = parseFeatureDoc(readFileSync(featureDocPath(cwd, "demo"), "utf8")).doc.tasks[0];
|
|
210
|
+
assert.equal(task.checked && task.candidate, "7c0ffee");
|
|
211
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gonrocca/nodd",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Non-negotiable Organic Driven Development \u2014 the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"nodd",
|
|
11
|
+
"odd",
|
|
12
|
+
"ai-coding-agent"
|
|
13
|
+
],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./extensions/nodd-kernel.ts",
|
|
17
|
+
"./extensions/nodd-gates.ts",
|
|
18
|
+
"./extensions/nodd-allow.ts",
|
|
19
|
+
"./extensions/nodd-models.ts",
|
|
20
|
+
"./extensions/nodd-agents.ts",
|
|
21
|
+
"./extensions/nodd-promote.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"extensions",
|
|
27
|
+
"test",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test --experimental-strip-types"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.6.0"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { classifyBash, COVERED_PATTERNS, NOT_COVERED } from "./bash-classifier.ts";
|
|
7
|
+
|
|
8
|
+
const CASES: Array<[string, "mutating" | "non-mutating"]> = [
|
|
9
|
+
// --- mutating ---
|
|
10
|
+
["echo hi > f.txt", "mutating"],
|
|
11
|
+
["cat a >> b", "mutating"],
|
|
12
|
+
["ls | tee out.txt", "mutating"],
|
|
13
|
+
["sed -i 's/a/b/' f.ts", "mutating"],
|
|
14
|
+
["perl -i -pe 's/x/y/' f.ts", "mutating"],
|
|
15
|
+
["mv a b", "mutating"],
|
|
16
|
+
["cp a b", "mutating"],
|
|
17
|
+
["rm -rf build", "mutating"],
|
|
18
|
+
["rmdir empty", "mutating"],
|
|
19
|
+
["ln -s a b", "mutating"],
|
|
20
|
+
["install -m 755 a /usr/local/bin/a", "mutating"],
|
|
21
|
+
["dd if=/dev/zero of=f bs=1M count=1", "mutating"],
|
|
22
|
+
["truncate -s 0 log", "mutating"],
|
|
23
|
+
["touch newfile", "mutating"],
|
|
24
|
+
["mkdir -p src/new", "mutating"],
|
|
25
|
+
["chmod +x run.sh", "mutating"],
|
|
26
|
+
["chown gon:gon f", "mutating"],
|
|
27
|
+
["patch -p1 < fix.diff", "mutating"],
|
|
28
|
+
["git apply fix.patch", "mutating"],
|
|
29
|
+
["git checkout -- src/a.ts", "mutating"],
|
|
30
|
+
["git restore src/a.ts", "mutating"],
|
|
31
|
+
["git reset --hard HEAD", "mutating"],
|
|
32
|
+
["git commit -m 'x'", "mutating"],
|
|
33
|
+
["git stash", "mutating"],
|
|
34
|
+
["git clean -fd", "mutating"],
|
|
35
|
+
["npm install lodash", "mutating"],
|
|
36
|
+
["pnpm add -D vitest", "mutating"],
|
|
37
|
+
["yarn add react", "mutating"],
|
|
38
|
+
["pip install requests", "mutating"],
|
|
39
|
+
["cargo add serde", "mutating"],
|
|
40
|
+
["node -e \"require('fs').writeFileSync('f','x')\"", "mutating"],
|
|
41
|
+
["python -c \"open('f','w').write('x')\"", "mutating"],
|
|
42
|
+
["ls && echo x > f", "mutating"],
|
|
43
|
+
|
|
44
|
+
// --- non-mutating: the ones a wrong gate would break ---
|
|
45
|
+
["ls -la", "non-mutating"],
|
|
46
|
+
["grep -rn 'a>b' src", "non-mutating"],
|
|
47
|
+
["cmd 2>&1", "non-mutating"],
|
|
48
|
+
["npm test 2>&1 | tail -5", "non-mutating"],
|
|
49
|
+
["git status", "non-mutating"],
|
|
50
|
+
["git log --oneline -5", "non-mutating"],
|
|
51
|
+
["git diff", "non-mutating"],
|
|
52
|
+
["git show HEAD", "non-mutating"],
|
|
53
|
+
["npm test", "non-mutating"],
|
|
54
|
+
["node --test", "non-mutating"],
|
|
55
|
+
["cat package.json", "non-mutating"],
|
|
56
|
+
["find . -name '*.ts'", "non-mutating"],
|
|
57
|
+
["echo hello", "non-mutating"],
|
|
58
|
+
["pwd", "non-mutating"],
|
|
59
|
+
["wc -l src/*.ts", "non-mutating"],
|
|
60
|
+
["rg 'pattern' --files-with-matches", "non-mutating"],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
test(`the classifier labels ${CASES.length} commands correctly`, () => {
|
|
64
|
+
for (const [command, expected] of CASES) {
|
|
65
|
+
assert.equal(classifyBash(command), expected, `"${command}" should be ${expected}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the covered pattern list is non-empty and every pattern matches something", () => {
|
|
70
|
+
assert.ok(COVERED_PATTERNS.length > 0);
|
|
71
|
+
for (const entry of COVERED_PATTERNS) {
|
|
72
|
+
assert.ok(entry.label.length > 0, "every covered row carries a label");
|
|
73
|
+
assert.ok(entry.example.length > 0, "every covered row carries an example");
|
|
74
|
+
assert.equal(classifyBash(entry.example), "mutating", `the example "${entry.example}" must classify as mutating`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the not-covered list is non-empty and names scripts and indirect writers", () => {
|
|
79
|
+
assert.ok(NOT_COVERED.length > 0);
|
|
80
|
+
const all = NOT_COVERED.join(" ").toLowerCase();
|
|
81
|
+
for (const word of ["script", "make", "compiler", "eval", "background", "outside pi"]) {
|
|
82
|
+
assert.ok(all.includes(word), `the not-covered list must name "${word}"`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const README = readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), "README.md"), "utf8");
|
|
87
|
+
|
|
88
|
+
function bashSection(): string {
|
|
89
|
+
const start = README.indexOf("## The bash gate");
|
|
90
|
+
assert.ok(start >= 0, "README must carry a `## The bash gate` section");
|
|
91
|
+
const rest = README.slice(start + 1);
|
|
92
|
+
const end = rest.indexOf("\n## ");
|
|
93
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("every covered row in the README matches the classifier's own pattern list", () => {
|
|
97
|
+
const section = bashSection();
|
|
98
|
+
for (const entry of COVERED_PATTERNS) {
|
|
99
|
+
assert.ok(section.includes(entry.label), `README's covered table is missing "${entry.label}"`);
|
|
100
|
+
}
|
|
101
|
+
for (const item of NOT_COVERED) {
|
|
102
|
+
assert.ok(section.includes(item), `README's not-covered list is missing "${item}"`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ODD's original sin was claiming compliance while shipping delivery. A
|
|
107
|
+
// denylist is a partial mechanism, and the documentation says so in the same
|
|
108
|
+
// place it lists what is covered.
|
|
109
|
+
test("the bash-gate section claims no guarantee it cannot keep", () => {
|
|
110
|
+
const section = bashSection().toLowerCase();
|
|
111
|
+
for (const word of ["guarantees", "garantiza", "exhaustive", "all writes"]) {
|
|
112
|
+
assert.ok(!section.includes(word), `the bash-gate section must not contain "${word}"`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Does this bash command mutate the filesystem?
|
|
2
|
+
//
|
|
3
|
+
// `tool_call` hands us `input.command` as a string (`types.d.ts:653-656`), not
|
|
4
|
+
// a declaration of filesystem intent. `write` and `edit` are typed and gated
|
|
5
|
+
// exactly; bash can only be gated by pattern. This is a denylist, and a
|
|
6
|
+
// denylist is by construction incomplete.
|
|
7
|
+
//
|
|
8
|
+
// That incompleteness is shipped as documentation, not buried here:
|
|
9
|
+
// `NOT_COVERED` below is rendered into README.md and asserted against by test,
|
|
10
|
+
// so the product cannot quietly imply a coverage it does not have. An honest
|
|
11
|
+
// partial gate beats a dishonest total one.
|
|
12
|
+
|
|
13
|
+
export type BashClass = "mutating" | "non-mutating";
|
|
14
|
+
|
|
15
|
+
export type CoveredPattern = {
|
|
16
|
+
label: string;
|
|
17
|
+
example: string;
|
|
18
|
+
test(command: string): boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A command word at the start of the string or after a shell separator. */
|
|
22
|
+
function commandWord(words: string[]): RegExp {
|
|
23
|
+
return new RegExp(String.raw`(^|[;&|]|&&|\|\|)\s*(sudo\s+)?(${words.join("|")})\b`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Output redirection, excluding the fd-only forms. `2>&1` and `>&2` redirect a
|
|
28
|
+
* descriptor, they do not create a file; treating them as writes would block
|
|
29
|
+
* `npm test 2>&1`, which is the single most common harmless command there is.
|
|
30
|
+
*/
|
|
31
|
+
function redirectsToFile(command: string): boolean {
|
|
32
|
+
const withoutFdDuplication = command.replace(/\d*>&\d+/g, "").replace(/&>/g, ">");
|
|
33
|
+
// A `>` inside quotes is data, not redirection (`grep -rn 'a>b' src`).
|
|
34
|
+
const unquoted = withoutFdDuplication.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
35
|
+
return />>?\s*\S/.test(unquoted);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const COVERED_PATTERNS: CoveredPattern[] = [
|
|
39
|
+
{ label: "output redirection (`>`, `>>`), excluding fd-only forms like `2>&1`", example: "echo hi > f.txt", test: redirectsToFile },
|
|
40
|
+
{ label: "`tee`", example: "ls | tee out.txt", test: (c) => commandWord(["tee"]).test(c) || /\|\s*(sudo\s+)?tee\b/.test(c) },
|
|
41
|
+
{ label: "in-place editors (`sed -i`, `perl -i`)", example: "sed -i 's/a/b/' f.ts", test: (c) => /\b(sed|perl)\b[^;&|]*\s-i\b/.test(c) },
|
|
42
|
+
{ label: "movers and removers (`mv`, `cp`, `rm`, `rmdir`, `ln`, `install`, `dd`, `truncate`, `touch`, `mkdir`)", example: "rm -rf build", test: (c) => commandWord(["mv", "cp", "rm", "rmdir", "ln", "install", "dd", "truncate", "touch", "mkdir"]).test(c) },
|
|
43
|
+
{ label: "permission changes (`chmod`, `chown`)", example: "chmod +x run.sh", test: (c) => commandWord(["chmod", "chown"]).test(c) },
|
|
44
|
+
{ label: "`patch`", example: "patch -p1 < fix.diff", test: (c) => commandWord(["patch"]).test(c) },
|
|
45
|
+
{ label: "mutating `git` subcommands (`apply`, `checkout`, `restore`, `reset`, `commit`, `stash`, `clean`, `mv`, `rm`)", example: "git commit -m 'x'", test: (c) => /\bgit\s+(apply|checkout|restore|reset|commit|stash|clean|mv|rm)\b/.test(c) },
|
|
46
|
+
{ label: "package installers (`npm`/`pnpm`/`yarn`/`pip`/`cargo` install or add)", example: "npm install lodash", test: (c) => /\b(npm|pnpm|yarn|pip|pip3|cargo)\s+(install|add|i)\b/.test(c) },
|
|
47
|
+
{ label: "inline interpreters (`node -e`, `python -c`)", example: "node -e \"require('fs').writeFileSync('f','x')\"", test: (c) => /\b(node|deno|bun)\s+(-e|--eval)\b/.test(c) || /\bpython3?\s+-c\b/.test(c) },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The mutation vectors this classifier does **not** catch. Shipped in README.md
|
|
52
|
+
* beside the covered table, because a partial mechanism that presents itself as
|
|
53
|
+
* total is how ODD ended up promising compliance while shipping delivery.
|
|
54
|
+
*/
|
|
55
|
+
export const NOT_COVERED: string[] = [
|
|
56
|
+
"a script or build target that writes: `./build.sh`, `make`, `npm run build`",
|
|
57
|
+
"compilers, formatters and codegen writing as a side effect",
|
|
58
|
+
"redirection hidden behind a variable or `eval`",
|
|
59
|
+
"a pre-existing background process",
|
|
60
|
+
"writes performed by other extensions' or MCP tools",
|
|
61
|
+
"writes performed outside pi entirely",
|
|
62
|
+
"a delegated child launched with its own `extensions:` list, which pi-subagents starts with `--no-extensions`",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function classifyBash(command: string): BashClass {
|
|
66
|
+
const text = (command ?? "").trim();
|
|
67
|
+
if (!text) return "non-mutating";
|
|
68
|
+
return COVERED_PATTERNS.some((pattern) => pattern.test(text)) ? "mutating" : "non-mutating";
|
|
69
|
+
}
|