@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,193 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { emptyCommitted, type Committed } from "./state.ts";
|
|
4
|
+
import { emptyPolicy, type Policy } from "./gates/policy.ts";
|
|
5
|
+
import { CANONICAL_STEPS } from "./manifest.ts";
|
|
6
|
+
import { ODD_PROSE, type ProseEntry } from "./odd-prose.ts";
|
|
7
|
+
import { BLOCK_A_BUDGET, BLOCK_B_BUDGET, renderBlockA, renderBlockB, renderPrompt } from "./prompt.ts";
|
|
8
|
+
|
|
9
|
+
function committed(overrides: Partial<Committed> = {}): Committed {
|
|
10
|
+
return { ...emptyCommitted(), ...overrides };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const undeclared = committed();
|
|
14
|
+
|
|
15
|
+
const tracked = committed({
|
|
16
|
+
declaration: { intent: "change", route: "tracked", slug: "cache-warmup" },
|
|
17
|
+
filesRead: new Set(["/repo/a.ts", "/repo/b.ts"]),
|
|
18
|
+
filesWritten: new Set(["/repo/a.ts"]),
|
|
19
|
+
toolCalls: 12,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const allOff: Policy = { ...emptyPolicy(), flags: { all: false } };
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Block A — the state
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
test("an undeclared session is told what is missing and how to declare it", () => {
|
|
28
|
+
const { blockA } = renderPrompt(undeclared, emptyPolicy());
|
|
29
|
+
assert.match(blockA, /nodd_declare/, "the unblocking action is named");
|
|
30
|
+
assert.match(blockA, /authorize/, "the current step is named");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a tracked session names its slug, its route and the enabled gates", () => {
|
|
34
|
+
const { blockA } = renderPrompt(tracked, emptyPolicy());
|
|
35
|
+
assert.ok(blockA.includes("cache-warmup"));
|
|
36
|
+
assert.ok(blockA.includes("tracked"));
|
|
37
|
+
assert.ok(blockA.includes("evidence"), "the evidence gate is listed as enabled");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the escape-hatch syntax is always available in block A", () => {
|
|
41
|
+
assert.match(renderPrompt(tracked, emptyPolicy()).blockA, /\/nodd-allow/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a used hatch is reported, so a one-shot override is not silently spent", () => {
|
|
45
|
+
const policy: Policy = { ...emptyPolicy(), hatches: { track: { reason: "hand-edit" } } };
|
|
46
|
+
const { blockA } = renderPrompt(tracked, policy);
|
|
47
|
+
assert.match(blockA, /track/);
|
|
48
|
+
assert.match(blockA, /hatch|habilitad|override/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Disabled gates
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
test("with every gate off block A is a single line", () => {
|
|
55
|
+
const { blockA } = renderPrompt(tracked, allOff);
|
|
56
|
+
assert.equal(blockA.trim().split("\n").length, 1, `expected one line, got:\n${blockA}`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the disabled notice never suggests re-enabling anything", () => {
|
|
60
|
+
const policies: Policy[] = [allOff, { ...emptyPolicy(), config: { track: { enabled: false } } }];
|
|
61
|
+
for (const policy of policies) {
|
|
62
|
+
const { blockA } = renderPrompt(tracked, policy);
|
|
63
|
+
for (const forbidden of ["re-enable", "reenable", "volvé a activar", "turn it back on", "reactivar", "nodd-gates on"]) {
|
|
64
|
+
assert.ok(!blockA.toLowerCase().includes(forbidden.toLowerCase()), `block A must not say "${forbidden}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a gate turned off is not listed as enabled", () => {
|
|
70
|
+
const policy: Policy = { ...emptyPolicy(), config: { track: { enabled: false } } };
|
|
71
|
+
const { blockA } = renderPrompt(tracked, policy);
|
|
72
|
+
const enabledLine = blockA.split("\n").find((line) => /activ|enabled/i.test(line)) ?? "";
|
|
73
|
+
assert.ok(!enabledLine.includes("track"), `track must not appear as enabled: ${enabledLine}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Block B — forwarded prose, scoped to the step
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
test("block B still forwards step prose even with every gate off", () => {
|
|
80
|
+
const { blockB } = renderPrompt(tracked, allOff);
|
|
81
|
+
assert.ok(blockB.length > 0, "guidance survives the kill switch; only enforcement stops");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("block B for implement carries the line advisory and no explore-only clause", () => {
|
|
85
|
+
const state = committed({ declaration: { intent: "change", route: "tracked", slug: "s" }, filesWritten: new Set(["/a.ts"]) });
|
|
86
|
+
const { blockB } = renderPrompt(state, emptyPolicy(), { step: "implement" });
|
|
87
|
+
assert.match(blockB, /400/, "the implement step carries the advisory");
|
|
88
|
+
assert.ok(!blockB.includes("Proportionality"), "an explore-only reason must not leak in");
|
|
89
|
+
assert.ok(!blockB.includes("proportionate to the request"), "an explore-only clause must not leak in");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("block B for explore carries the preparation trigger and not the line advisory", () => {
|
|
93
|
+
const { blockB } = renderPrompt(tracked, emptyPolicy(), { step: "explore" });
|
|
94
|
+
assert.match(blockB, /prepar/i);
|
|
95
|
+
assert.ok(!blockB.includes("400"), "the line figure belongs to implement");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// The budgets — this is the anti-ratchet mechanism
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
test("the budgets are the documented numbers", () => {
|
|
102
|
+
assert.equal(BLOCK_A_BUDGET, 1500);
|
|
103
|
+
assert.equal(BLOCK_B_BUDGET, 2500);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// This is the anti-ratchet assertion, and it is deliberately made on the text
|
|
107
|
+
// *before* `renderPrompt` cuts it. Asserting on the emitted block would be
|
|
108
|
+
// `min(len, budget) <= budget` — an assertion no corpus on earth can fail,
|
|
109
|
+
// which is exactly the shape the round-1 verdict caught. Here, one clause too
|
|
110
|
+
// many turns this red.
|
|
111
|
+
test("every step, every policy, stays inside both budgets BEFORE any truncation", () => {
|
|
112
|
+
const states = [undeclared, tracked];
|
|
113
|
+
const policies = [emptyPolicy(), allOff, { ...emptyPolicy(), hatches: { track: { reason: "x" } } }];
|
|
114
|
+
|
|
115
|
+
for (const step of CANONICAL_STEPS) {
|
|
116
|
+
const blockB = renderBlockB(step);
|
|
117
|
+
assert.ok(blockB.length <= BLOCK_B_BUDGET, `block B at ${step} is ${blockB.length} > ${BLOCK_B_BUDGET} before truncation`);
|
|
118
|
+
for (const state of states) {
|
|
119
|
+
for (const policy of policies) {
|
|
120
|
+
const blockA = renderBlockA(state, policy, step);
|
|
121
|
+
assert.ok(blockA.length <= BLOCK_A_BUDGET, `block A at ${step} is ${blockA.length} > ${BLOCK_A_BUDGET} before truncation`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// The test above is only a defence if it can fail. This one proves it can, by
|
|
128
|
+
// doing to the corpus exactly what the verdict did: growing it. If this ever
|
|
129
|
+
// stops throwing, the assertion above has gone tautological again.
|
|
130
|
+
test("growing the corpus past the budget makes the anti-ratchet assertion fail", () => {
|
|
131
|
+
const padding: ProseEntry[] = Array.from({ length: 40 }, (_, i) => ({
|
|
132
|
+
row: 1000 + i,
|
|
133
|
+
line: ":999",
|
|
134
|
+
step: "implement" as const,
|
|
135
|
+
clause: `Filler clause ${i}: individually defensible, collectively a wall of prose.`,
|
|
136
|
+
reason: "a padding entry, here only to prove the budget assertion is reachable",
|
|
137
|
+
}));
|
|
138
|
+
const grown = [...ODD_PROSE.filter((entry) => entry.step === "implement"), ...padding];
|
|
139
|
+
|
|
140
|
+
const rendered = renderBlockB("implement", grown);
|
|
141
|
+
assert.ok(
|
|
142
|
+
rendered.length > BLOCK_B_BUDGET,
|
|
143
|
+
"40 extra clauses must overflow the budget; if they do not, the budget is not measuring the corpus",
|
|
144
|
+
);
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => assert.ok(rendered.length <= BLOCK_B_BUDGET),
|
|
147
|
+
"the anti-ratchet assertion must reject a grown corpus",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a real ODD clause is never silently dropped to make room", () => {
|
|
152
|
+
// The verdict's worst finding: with padding injected first, all four real
|
|
153
|
+
// `implement` clauses fell out of the emitted block and nothing said so.
|
|
154
|
+
const real = ODD_PROSE.filter((entry) => entry.step === "implement");
|
|
155
|
+
const blockB = renderBlockB("implement");
|
|
156
|
+
for (const entry of real) {
|
|
157
|
+
assert.ok(blockB.includes(entry.clause), `row ${entry.row} must survive into block B whole`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a mid-implementation prompt stays under 4000 characters combined", () => {
|
|
162
|
+
const { blockA, blockB } = renderPrompt(tracked, emptyPolicy(), { step: "implement" });
|
|
163
|
+
assert.ok(blockA.length + blockB.length <= 4000, `combined ${blockA.length + blockB.length}`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an over-budget block is cut, and the cut is announced in the block itself", () => {
|
|
167
|
+
// Production must not lose characters quietly. The cut stays — the budget is
|
|
168
|
+
// the point — but the block says it happened and by how much, so the next
|
|
169
|
+
// author meets the decision instead of the loss.
|
|
170
|
+
const { blockB, overBudget } = renderPrompt(tracked, emptyPolicy(), { step: "implement", blockBBudget: 200 });
|
|
171
|
+
assert.ok(blockB.length <= 200, `truncated block is ${blockB.length}`);
|
|
172
|
+
assert.match(blockB, /NODD/, "the notice names who cut it");
|
|
173
|
+
assert.match(blockB, /over budget|cut/i, "a truncated block says it was truncated");
|
|
174
|
+
assert.match(blockB, new RegExp(String(renderBlockB("implement").length)), "and reports the real pre-cut size");
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
overBudget.length,
|
|
177
|
+
1,
|
|
178
|
+
"an overflow is reported to the caller, not only buried in the text",
|
|
179
|
+
);
|
|
180
|
+
assert.match(overBudget[0], /block B/, overBudget[0]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a block inside its budget reports no overflow and carries no notice", () => {
|
|
184
|
+
const { blockA, blockB, overBudget } = renderPrompt(tracked, emptyPolicy(), { step: "implement" });
|
|
185
|
+
assert.deepEqual(overBudget, [], "the shipped corpus fits, so nothing is announced");
|
|
186
|
+
assert.ok(!blockA.includes("over budget") && !blockB.includes("over budget"));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("the renderer is pure: same inputs, same bytes, no file access", () => {
|
|
190
|
+
const first = renderPrompt(tracked, emptyPolicy(), { step: "implement" });
|
|
191
|
+
const second = renderPrompt(tracked, emptyPolicy(), { step: "implement" });
|
|
192
|
+
assert.deepEqual(first, second);
|
|
193
|
+
});
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The dynamic prompt: two blocks, both budgeted.
|
|
2
|
+
//
|
|
3
|
+
// Block A is the session's *state* — current step, enabled gates, what is
|
|
4
|
+
// missing, how to unblock. Block B is the forwarded prose for that step and
|
|
5
|
+
// nothing else (`src/odd-prose.ts`).
|
|
6
|
+
//
|
|
7
|
+
// ## Why there are hard budgets
|
|
8
|
+
//
|
|
9
|
+
// gentle's guidance surface reached **105,993 bytes** one defensible clause at a
|
|
10
|
+
// time. No single addition was wrong; the sum was. A budget is the only defence
|
|
11
|
+
// that does not depend on the next author's restraint, so these two numbers are
|
|
12
|
+
// enforced by truncation and pinned by a test that fails if a block grows past
|
|
13
|
+
// them. Adding a clause that does not fit is therefore a decision to remove or
|
|
14
|
+
// shorten another — which is exactly the conversation that never happened in
|
|
15
|
+
// gentle.
|
|
16
|
+
//
|
|
17
|
+
// For that to be a defence rather than a story, two things have to hold, and
|
|
18
|
+
// round 1 of this build had neither:
|
|
19
|
+
//
|
|
20
|
+
// - **the test measures the corpus, not the cut.** `renderBlockA`/`renderBlockB`
|
|
21
|
+
// are exported *unbudgeted* so `prompt.test.ts` can assert on what the
|
|
22
|
+
// corpus renders to. Asserting on `renderPrompt`'s output instead compares
|
|
23
|
+
// the truncator's output to the truncator's own limit, which no corpus can
|
|
24
|
+
// fail.
|
|
25
|
+
// - **overflow is loud.** A cut still happens — the budget is the mechanism —
|
|
26
|
+
// but the emitted block says it was cut and by how much, and `renderPrompt`
|
|
27
|
+
// returns the overflows so the caller can surface them. A silently dropped
|
|
28
|
+
// clause is the exact failure this budget exists to prevent, so it must not
|
|
29
|
+
// be how the budget is enforced.
|
|
30
|
+
//
|
|
31
|
+
// ## Guidance survives the kill switch
|
|
32
|
+
//
|
|
33
|
+
// With every gate disabled, block A collapses to one line and block B still
|
|
34
|
+
// forwards the step's prose: turning enforcement off is not a request to stop
|
|
35
|
+
// being useful. And the disabled notice contains **no suggestion to re-enable**
|
|
36
|
+
// (`routing.go:116`) — "do not start it, do not retry, do not reactivate".
|
|
37
|
+
|
|
38
|
+
import { GATE_IDS, type GateId } from "./gates/registry.ts";
|
|
39
|
+
import { resolveFlag, type Policy } from "./gates/policy.ts";
|
|
40
|
+
import { proseForStep, type ProseEntry } from "./odd-prose.ts";
|
|
41
|
+
import type { CanonicalStep } from "./manifest.ts";
|
|
42
|
+
import type { Committed } from "./state.ts";
|
|
43
|
+
|
|
44
|
+
/** Hard ceilings. See the ratchet note above before raising either. */
|
|
45
|
+
export const BLOCK_A_BUDGET = 1500;
|
|
46
|
+
export const BLOCK_B_BUDGET = 2500;
|
|
47
|
+
|
|
48
|
+
export type Prompt = {
|
|
49
|
+
blockA: string;
|
|
50
|
+
blockB: string;
|
|
51
|
+
/** One line per block that did not fit. Empty when everything fitted. */
|
|
52
|
+
overBudget: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RenderOptions = {
|
|
56
|
+
step?: CanonicalStep;
|
|
57
|
+
blockABudget?: number;
|
|
58
|
+
blockBBudget?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Where the session is, derived from what it has actually done. */
|
|
62
|
+
function currentStep(committed: Committed): CanonicalStep {
|
|
63
|
+
if (committed.declaration === null) return committed.toolCalls === 0 ? "authorize" : "classify";
|
|
64
|
+
if (committed.declaration.intent === "read-only") return "explore";
|
|
65
|
+
if (committed.filesWritten.size > 0) return "implement";
|
|
66
|
+
return committed.declaration.route === "inline" ? "implement" : "track";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function enabledGates(policy: Policy): GateId[] {
|
|
70
|
+
return GATE_IDS.filter((gate) => resolveFlag(gate, policy).enabled);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cut to budget and say so, in the block and to the caller. The notice is part
|
|
75
|
+
* of the budget: it is rendered inside the remaining room, so an over-budget
|
|
76
|
+
* block is still an under-budget block, just a visibly damaged one.
|
|
77
|
+
*/
|
|
78
|
+
function fit(text: string, budget: number, label: string): { text: string; problem: string | null } {
|
|
79
|
+
if (text.length <= budget) return { text, problem: null };
|
|
80
|
+
|
|
81
|
+
const problem = `${label} is ${text.length} characters, over its ${budget}-character budget: ${text.length - budget} were cut. Shorten or remove a clause instead of raising the budget.`;
|
|
82
|
+
const notice = `\n[NODD: ${label} over budget — ${text.length}/${budget} characters, cut]`;
|
|
83
|
+
const room = Math.max(0, budget - notice.length);
|
|
84
|
+
return { text: `${text.slice(0, room).trimEnd()}${notice}`, problem };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Block A at full length, unbudgeted. The budget is applied by `renderPrompt`. */
|
|
88
|
+
export function renderBlockA(committed: Committed, policy: Policy, step: CanonicalStep): string {
|
|
89
|
+
const enabled = enabledGates(policy);
|
|
90
|
+
|
|
91
|
+
if (enabled.length === 0) {
|
|
92
|
+
// One line, and not a word about turning anything back on.
|
|
93
|
+
return "NODD: gates disabled. Working normally; no enforcement is applied.";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lines = [`NODD · step: ${step} · gates activos: ${enabled.join(", ")}`];
|
|
97
|
+
|
|
98
|
+
if (committed.declaration === null) {
|
|
99
|
+
lines.push("Sin declaración: el primer write está bloqueado hasta que llames `nodd_declare` con intent y route.");
|
|
100
|
+
} else {
|
|
101
|
+
const { intent, route, slug } = committed.declaration;
|
|
102
|
+
lines.push(`Declarado: intent ${intent} · route ${route} · slug ${slug}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const disabled = GATE_IDS.filter((gate) => !resolveFlag(gate, policy).enabled);
|
|
106
|
+
if (disabled.length > 0) lines.push(`Apagados por el usuario: ${disabled.join(", ")}.`);
|
|
107
|
+
|
|
108
|
+
const hatches = Object.keys(policy.hatches);
|
|
109
|
+
if (hatches.length > 0) lines.push(`Hatch de un uso pendiente: ${hatches.join(", ")}.`);
|
|
110
|
+
|
|
111
|
+
lines.push("Para saltear un gate una vez: `/nodd-allow <gate>`.");
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Block B at full length, unbudgeted. `entries` is injectable so the anti-ratchet
|
|
117
|
+
* test can render a grown corpus and prove the budget assertion is reachable.
|
|
118
|
+
*/
|
|
119
|
+
export function renderBlockB(step: CanonicalStep, entries: readonly ProseEntry[] = proseForStep(step)): string {
|
|
120
|
+
if (entries.length === 0) return "";
|
|
121
|
+
// Only the clause text: the row number and the not-mechanizable reason are
|
|
122
|
+
// there for the reader of the corpus, and spending budget on them would push
|
|
123
|
+
// the useful half out.
|
|
124
|
+
return [`ODD · guía para ${step} (orientativa, no verificada):`, ...entries.map((entry) => `- ${entry.clause}`)].join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function renderPrompt(committed: Committed, policy: Policy, options: RenderOptions = {}): Prompt {
|
|
128
|
+
const step = options.step ?? currentStep(committed);
|
|
129
|
+
const a = fit(renderBlockA(committed, policy, step), options.blockABudget ?? BLOCK_A_BUDGET, "block A");
|
|
130
|
+
const b = fit(renderBlockB(step), options.blockBBudget ?? BLOCK_B_BUDGET, "block B");
|
|
131
|
+
return {
|
|
132
|
+
blockA: a.text,
|
|
133
|
+
blockB: b.text,
|
|
134
|
+
overBudget: [a.problem, b.problem].filter((problem): problem is string => problem !== null),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { emptyCommitted, fold, type Committed } from "./state.ts";
|
|
5
|
+
import { observation } from "./observations.ts";
|
|
6
|
+
import {
|
|
7
|
+
candidateFor,
|
|
8
|
+
chainBoundary,
|
|
9
|
+
parseCommitSha,
|
|
10
|
+
renderCandidate,
|
|
11
|
+
sliceCandidate,
|
|
12
|
+
type Boundary,
|
|
13
|
+
} from "./review-candidate.ts";
|
|
14
|
+
|
|
15
|
+
function afterCommands(runs: Array<{ command: string; text: string; isError?: boolean }>): Committed {
|
|
16
|
+
let committed = emptyCommitted();
|
|
17
|
+
runs.forEach((run, i) => {
|
|
18
|
+
committed = fold(committed, observation({
|
|
19
|
+
toolCallId: `c${i}`,
|
|
20
|
+
toolName: "bash",
|
|
21
|
+
input: { command: run.command },
|
|
22
|
+
isError: run.isError === true,
|
|
23
|
+
resultText: run.text,
|
|
24
|
+
at: `2026-09-19T10:0${i}:00.000Z`,
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
return committed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("checking off with an observed commit records that SHA as the candidate", () => {
|
|
31
|
+
const committed = afterCommands([
|
|
32
|
+
{ command: "git commit -m 'feat: thing'", text: "[main 1a2b3c4] feat: thing\n 2 files changed" },
|
|
33
|
+
]);
|
|
34
|
+
const candidate = candidateFor(committed);
|
|
35
|
+
assert.deepEqual(candidate, { kind: "commit", sha: "1a2b3c4" });
|
|
36
|
+
assert.equal(renderCandidate(candidate), "candidate: 1a2b3c4");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a full SHA from git rev-parse is recorded too", () => {
|
|
40
|
+
const sha = "0123456789abcdef0123456789abcdef01234567";
|
|
41
|
+
const committed = afterCommands([{ command: "git rev-parse HEAD", text: sha }]);
|
|
42
|
+
assert.deepEqual(candidateFor(committed), { kind: "commit", sha });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the latest observed commit wins", () => {
|
|
46
|
+
const committed = afterCommands([
|
|
47
|
+
{ command: "git commit -m 'one'", text: "[main aaaaaaa] one" },
|
|
48
|
+
{ command: "git commit -m 'two'", text: "[main bbbbbbb] two" },
|
|
49
|
+
]);
|
|
50
|
+
assert.deepEqual(candidateFor(committed), { kind: "commit", sha: "bbbbbbb" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("checking off with no observed commit records pending-commit, never the checkbox", () => {
|
|
54
|
+
const candidate = candidateFor(afterCommands([{ command: "npm test", text: "42 passing" }]));
|
|
55
|
+
assert.deepEqual(candidate, { kind: "pending-commit" });
|
|
56
|
+
assert.equal(renderCandidate(candidate), "candidate: pending-commit");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a failed commit is not a candidate: the SHA must come from a success", () => {
|
|
60
|
+
const committed = afterCommands([
|
|
61
|
+
{ command: "git commit -m 'x'", text: "nothing to commit\n\nCommand exited with code 1", isError: true },
|
|
62
|
+
]);
|
|
63
|
+
assert.deepEqual(candidateFor(committed), { kind: "pending-commit" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("prose that merely looks like commit output is not a candidate", () => {
|
|
67
|
+
// The model may say anything; only a NODD-issued git command's observed
|
|
68
|
+
// output is a source of SHAs.
|
|
69
|
+
const claimed = fold(emptyCommitted(), observation({
|
|
70
|
+
toolCallId: "a1",
|
|
71
|
+
toolName: "assistant_message",
|
|
72
|
+
input: { text: "I committed it as [main deadbee] feat: thing" },
|
|
73
|
+
isError: false,
|
|
74
|
+
resultText: "",
|
|
75
|
+
at: "2026-09-19T10:00:00.000Z",
|
|
76
|
+
}));
|
|
77
|
+
assert.deepEqual(candidateFor(claimed), { kind: "pending-commit" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("boundary chaining: the first base is the branch point, each head becomes the next base", () => {
|
|
81
|
+
const recorded: Boundary[] = [];
|
|
82
|
+
const first = chainBoundary("origin/main", recorded, "slice1head");
|
|
83
|
+
assert.deepEqual(first, { base: "origin/main", head: "slice1head" });
|
|
84
|
+
|
|
85
|
+
recorded.push(first);
|
|
86
|
+
const second = chainBoundary("origin/main", recorded, "slice2head");
|
|
87
|
+
assert.deepEqual(second, { base: "slice1head", head: "slice2head" });
|
|
88
|
+
|
|
89
|
+
const candidate = sliceCandidate(second);
|
|
90
|
+
assert.deepEqual(candidate, { kind: "slice", base: "slice1head", head: "slice2head" });
|
|
91
|
+
assert.equal(renderCandidate(candidate), "candidate: slice slice1head..slice2head");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("parseCommitSha reads only the formats NODD issues", () => {
|
|
95
|
+
assert.equal(parseCommitSha("git commit -m 'x'", "[feature/x 9f8e7d6] x"), "9f8e7d6");
|
|
96
|
+
assert.equal(parseCommitSha("git commit --amend", "[main 1234567] x"), "1234567");
|
|
97
|
+
assert.equal(parseCommitSha("git log --oneline", "1234567 some commit"), null, "log is not a commit event");
|
|
98
|
+
assert.equal(parseCommitSha("npm test", "[main 1234567] looks like a commit"), null, "not a git command");
|
|
99
|
+
assert.equal(parseCommitSha("git commit -m 'x'", "nothing to commit"), null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The rule is structural: no candidate identity may ever be a task id or a
|
|
103
|
+
// checkbox state. `routing.go:51`,`:102`.
|
|
104
|
+
test("no candidate identity is a task id or a checkbox state", () => {
|
|
105
|
+
const identities = [
|
|
106
|
+
renderCandidate({ kind: "commit", sha: "1a2b3c4" }),
|
|
107
|
+
renderCandidate({ kind: "pending-commit" }),
|
|
108
|
+
renderCandidate(sliceCandidate({ base: "a", head: "b" })),
|
|
109
|
+
];
|
|
110
|
+
for (const rendered of identities) {
|
|
111
|
+
assert.doesNotMatch(rendered, /\bT\d+\b/, `${rendered} must not carry a task id`);
|
|
112
|
+
assert.doesNotMatch(rendered, /\b(true|false|\[x\]|\[ \])\b/, `${rendered} must not carry checkbox state`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const source = readFileSync(new URL("./review-candidate.ts", import.meta.url), "utf8");
|
|
116
|
+
assert.ok(!/\bchecked\b/.test(source), "the candidate module must not read checkbox state");
|
|
117
|
+
assert.ok(!/\btaskId\b/.test(source), "the candidate module must not take a task id");
|
|
118
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What gets reviewed is a commit or a slice — never a checkbox.
|
|
2
|
+
//
|
|
3
|
+
// `routing.go:51`,`:102`: "the native review candidate is a work-unit commit or
|
|
4
|
+
// a PR slice, never a TODO checkbox and never the accumulated branch". A
|
|
5
|
+
// checkbox is a claim the model wrote down; a commit is a thing that exists.
|
|
6
|
+
// This module only ever hands out the second kind.
|
|
7
|
+
//
|
|
8
|
+
// The SHA comes from the observed output of a NODD-issued `git` command, on the
|
|
9
|
+
// same rule as command outcomes (T019): the model's prose is not an input. When
|
|
10
|
+
// no commit has been observed the candidate is `pending-commit` — an honest
|
|
11
|
+
// absence, not a substitute identity.
|
|
12
|
+
//
|
|
13
|
+
// Slices carry an explicit (base, head) pair. The accumulated branch is never a
|
|
14
|
+
// candidate: "everything since we started" is not reviewable, which is the
|
|
15
|
+
// failure mode the clause exists to prevent.
|
|
16
|
+
|
|
17
|
+
import type { Committed } from "./state.ts";
|
|
18
|
+
import { parseOutcome, isSuccess } from "./outcome.ts";
|
|
19
|
+
|
|
20
|
+
export type Boundary = { base: string; head: string };
|
|
21
|
+
|
|
22
|
+
export type Candidate =
|
|
23
|
+
| { kind: "commit"; sha: string }
|
|
24
|
+
| { kind: "slice"; base: string; head: string }
|
|
25
|
+
| { kind: "pending-commit" };
|
|
26
|
+
|
|
27
|
+
/** `git commit` prints `[<branch> <sha>] <subject>`; `rev-parse` prints the sha alone. */
|
|
28
|
+
export function parseCommitSha(command: string, resultText: string): string | null {
|
|
29
|
+
if (!/\bgit\s+(commit|rev-parse)\b/.test(command)) return null;
|
|
30
|
+
|
|
31
|
+
const committed = /^\[[^\]\s]+ ([0-9a-f]{7,40})\]/m.exec(resultText);
|
|
32
|
+
if (committed) return committed[1];
|
|
33
|
+
|
|
34
|
+
if (/\bgit\s+rev-parse\b/.test(command)) {
|
|
35
|
+
const bare = /^([0-9a-f]{40})$/m.exec(resultText.trim());
|
|
36
|
+
if (bare) return bare[1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The candidate for a checkoff: the most recent SHA this session observed a
|
|
44
|
+
* successful `git` command produce, or `pending-commit`.
|
|
45
|
+
*/
|
|
46
|
+
export function candidateFor(committed: Committed): Candidate {
|
|
47
|
+
for (let i = committed.commandResults.length - 1; i >= 0; i--) {
|
|
48
|
+
const run = committed.commandResults[i];
|
|
49
|
+
if (!isSuccess(parseOutcome(run.isError, run.resultText))) continue;
|
|
50
|
+
const sha = parseCommitSha(run.command, run.resultText);
|
|
51
|
+
if (sha) return { kind: "commit", sha };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "pending-commit" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sliceCandidate(boundary: Boundary): Candidate {
|
|
57
|
+
return { kind: "slice", base: boundary.base, head: boundary.head };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The next boundary. The first base is the branch point; afterwards each
|
|
62
|
+
* recorded head becomes the next base, so slices tile the work instead of each
|
|
63
|
+
* one re-proposing the whole branch.
|
|
64
|
+
*/
|
|
65
|
+
export function chainBoundary(branchPoint: string, recorded: Boundary[], head: string): Boundary {
|
|
66
|
+
const previous = recorded[recorded.length - 1];
|
|
67
|
+
return { base: previous ? previous.head : branchPoint, head };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The identity alone, for the feature doc's `candidate:` field. */
|
|
71
|
+
export function candidateIdentity(candidate: Candidate): string {
|
|
72
|
+
switch (candidate.kind) {
|
|
73
|
+
case "commit": return candidate.sha;
|
|
74
|
+
case "slice": return `slice ${candidate.base}..${candidate.head}`;
|
|
75
|
+
case "pending-commit": return "pending-commit";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderCandidate(candidate: Candidate): string {
|
|
80
|
+
return `candidate: ${candidateIdentity(candidate)}`;
|
|
81
|
+
}
|