@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
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Delivery strategy: recorded and measured, never enforced.
|
|
2
|
+
//
|
|
3
|
+
// `routing.go:103` gives ODD's delivery vocabulary. NODD splits it honestly
|
|
4
|
+
// instead of pretending one mechanism covers it:
|
|
5
|
+
//
|
|
6
|
+
// - **record** (here): a `## Delivery` section holding strategy, chain,
|
|
7
|
+
// forecast, running count and the slice boundaries with their commits. Both
|
|
8
|
+
// choices are cached so they are asked once.
|
|
9
|
+
// - **measure** (here): the running count is additions plus deletions parsed
|
|
10
|
+
// from observed `git` diffstat output, with a documented generated-file
|
|
11
|
+
// exclusion list. Unparseable output leaves it `unknown` — same fail-closed
|
|
12
|
+
// rule as the command outcomes (T019). A guessed number would be worse than
|
|
13
|
+
// no number, because it would look like a measurement.
|
|
14
|
+
// - **ask** (prose): asking is conversation.
|
|
15
|
+
// - **execute** (out of scope): `/zero-branch` and `/zero-pr` already do it,
|
|
16
|
+
// and ODD itself says push, PR and merge stay the user's (`routing.go:50`).
|
|
17
|
+
//
|
|
18
|
+
// The ~400-line crossing **emits no block**. `routing.go:95` calls the figure a
|
|
19
|
+
// planning heuristic and explicitly not a hard cap, not an automatic stop and
|
|
20
|
+
// not a forced split. Turning it into a gate would violate the very clause it
|
|
21
|
+
// comes from, so `crossedForecast` returns a fact for the prompt to mention and
|
|
22
|
+
// nothing in `src/gates/` may import this module at all.
|
|
23
|
+
|
|
24
|
+
export type Strategy = "ask-on-risk" | "auto-chain" | "single-pr" | "exception-ok";
|
|
25
|
+
export type Chain = "stacked-to-main" | "feature-branch-chain";
|
|
26
|
+
|
|
27
|
+
const STRATEGIES: readonly string[] = ["ask-on-risk", "auto-chain", "single-pr", "exception-ok"];
|
|
28
|
+
const CHAINS: readonly string[] = ["stacked-to-main", "feature-branch-chain"];
|
|
29
|
+
|
|
30
|
+
export function isStrategy(value: string): value is Strategy {
|
|
31
|
+
return STRATEGIES.includes(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isChain(value: string): value is Chain {
|
|
35
|
+
return CHAINS.includes(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A slice's (base, head) pair. Chained by `src/review-candidate.ts`. */
|
|
39
|
+
export type DeliveryBoundary = { base: string; head: string };
|
|
40
|
+
|
|
41
|
+
export type Delivery = {
|
|
42
|
+
strategy: Strategy;
|
|
43
|
+
chain: Chain;
|
|
44
|
+
/** Authored changed lines forecast at feature-doc creation. */
|
|
45
|
+
forecast: number;
|
|
46
|
+
/** Measured so far, or `unknown` when no diffstat has been parsed. */
|
|
47
|
+
running: number | "unknown";
|
|
48
|
+
boundaries: DeliveryBoundary[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function defaultDelivery(): Delivery {
|
|
52
|
+
return {
|
|
53
|
+
// `ask-on-risk` is the default because the asking is the part NODD does not
|
|
54
|
+
// mechanize: defaulting to `auto-chain` would decide it silently.
|
|
55
|
+
strategy: "ask-on-risk",
|
|
56
|
+
chain: "stacked-to-main",
|
|
57
|
+
forecast: 0,
|
|
58
|
+
running: "unknown",
|
|
59
|
+
boundaries: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Paths whose churn is not authored work. Documented rather than inferred: a
|
|
65
|
+
* heuristic nobody can read is a heuristic nobody can correct.
|
|
66
|
+
*/
|
|
67
|
+
export const GENERATED_PATHS: RegExp[] = [
|
|
68
|
+
/(^|\/)package-lock\.json$/,
|
|
69
|
+
/(^|\/)pnpm-lock\.yaml$/,
|
|
70
|
+
/(^|\/)yarn\.lock$/,
|
|
71
|
+
/(^|\/)Cargo\.lock$/,
|
|
72
|
+
/(^|\/)poetry\.lock$/,
|
|
73
|
+
/(^|\/)(dist|build|out|coverage|node_modules|vendor)\//,
|
|
74
|
+
/\.min\.(js|css)$/,
|
|
75
|
+
/\.(snap|lock)$/,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function isGenerated(path: string): boolean {
|
|
79
|
+
return GENERATED_PATHS.some((pattern) => pattern.test(path));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Additions plus deletions of authored lines, from observed `git diff --stat`
|
|
84
|
+
* output. Per-file rows are the source: the summary line cannot be attributed to
|
|
85
|
+
* a path, so it cannot honour the exclusion list.
|
|
86
|
+
*
|
|
87
|
+
* Returns `unknown` when no per-file row parses. A diffstat whose every row is
|
|
88
|
+
* generated returns `0` — that is a measurement, not an absence.
|
|
89
|
+
*/
|
|
90
|
+
export function countAuthoredLines(gitOutput: string): number | "unknown" {
|
|
91
|
+
const rows = /^\s*(\S.*?)\s+\|\s+(\d+)\s*([+-]*)\s*$/gm;
|
|
92
|
+
let total = 0;
|
|
93
|
+
let parsedAny = false;
|
|
94
|
+
|
|
95
|
+
for (const match of (gitOutput ?? "").matchAll(rows)) {
|
|
96
|
+
const [, path, , marks] = match;
|
|
97
|
+
parsedAny = true;
|
|
98
|
+
if (isGenerated(path)) continue;
|
|
99
|
+
// The bar graph is scaled for wide diffs, so `+`/`-` counts are only
|
|
100
|
+
// trustworthy when they add up to the row's own total.
|
|
101
|
+
const changed = Number(match[2]);
|
|
102
|
+
const plus = (marks.match(/\+/g) ?? []).length;
|
|
103
|
+
const minus = (marks.match(/-/g) ?? []).length;
|
|
104
|
+
total += plus + minus === changed ? plus + minus : changed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return parsedAny ? total : "unknown";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the measured count passed the forecast. A fact for the dynamic prompt
|
|
112
|
+
* to mention. `unknown` never claims a crossing — fail-closed both ways.
|
|
113
|
+
*/
|
|
114
|
+
export function crossedForecast(delivery: Delivery): boolean {
|
|
115
|
+
return delivery.running !== "unknown" && delivery.forecast > 0 && delivery.running > delivery.forecast;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderDelivery(delivery: Delivery): string[] {
|
|
119
|
+
return [
|
|
120
|
+
`- strategy: ${delivery.strategy}`,
|
|
121
|
+
`- chain: ${delivery.chain}`,
|
|
122
|
+
`- forecast: ${delivery.forecast}`,
|
|
123
|
+
`- running: ${delivery.running}`,
|
|
124
|
+
...delivery.boundaries.map((b) => `- boundary: ${b.base}..${b.head}`),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseDelivery(block: string): Delivery {
|
|
129
|
+
const field = (name: string): string | null => {
|
|
130
|
+
const match = new RegExp(`^- ${name}: (.+)$`, "m").exec(block);
|
|
131
|
+
return match ? match[1].trim() : null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const strategy = field("strategy");
|
|
135
|
+
const chain = field("chain");
|
|
136
|
+
const running = field("running");
|
|
137
|
+
const forecast = Number(field("forecast"));
|
|
138
|
+
|
|
139
|
+
const boundaries: DeliveryBoundary[] = [];
|
|
140
|
+
for (const match of block.matchAll(/^- boundary: (\S+)\.\.(\S+)$/gm)) {
|
|
141
|
+
boundaries.push({ base: match[1], head: match[2] });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
strategy: strategy && isStrategy(strategy) ? strategy : "ask-on-risk",
|
|
146
|
+
chain: chain && isChain(chain) ? chain : "stacked-to-main",
|
|
147
|
+
forecast: Number.isFinite(forecast) ? forecast : 0,
|
|
148
|
+
running: running !== null && /^\d+$/.test(running) ? Number(running) : "unknown",
|
|
149
|
+
boundaries,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { emptyDoc, parseFeatureDoc, renderFeatureDoc, renderOutcome, type FeatureDoc } from "./feature-doc.ts";
|
|
4
|
+
|
|
5
|
+
function docWithTasks(n: number): FeatureDoc {
|
|
6
|
+
const doc = emptyDoc({ slug: "demo", title: "Demo feature" });
|
|
7
|
+
doc.objective = "Make the thing work.";
|
|
8
|
+
doc.problem = "The thing does not work.";
|
|
9
|
+
doc.scope = "- src/thing.ts";
|
|
10
|
+
doc.constraints = "- no new dependencies";
|
|
11
|
+
doc.route = { intent: "change", route: "tracked" };
|
|
12
|
+
doc.verification = { runner: "npm test", tdd: "off", source: "nodd_declare", files: ["src/thing.ts"] };
|
|
13
|
+
doc.progress = "- started";
|
|
14
|
+
for (let i = 1; i <= n; i++) {
|
|
15
|
+
doc.tasks.push(
|
|
16
|
+
i === 2
|
|
17
|
+
? {
|
|
18
|
+
id: `T${i}`,
|
|
19
|
+
title: `Task ${i}`,
|
|
20
|
+
checked: true,
|
|
21
|
+
evidence: { command: "npm test", outcome: "success" },
|
|
22
|
+
candidate: "1a2b3c4",
|
|
23
|
+
}
|
|
24
|
+
: { id: `T${i}`, title: `Task ${i}`, checked: false },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return doc;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const n of [0, 1, 3]) {
|
|
31
|
+
test(`parse(render(doc)) round-trips with ${n} tasks`, () => {
|
|
32
|
+
const doc = docWithTasks(n);
|
|
33
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(doc));
|
|
34
|
+
assert.deepEqual(parsed.defects, []);
|
|
35
|
+
assert.deepEqual(parsed.doc, doc);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("rendering is deterministic to the byte", () => {
|
|
40
|
+
const doc = docWithTasks(3);
|
|
41
|
+
assert.equal(renderFeatureDoc(doc), renderFeatureDoc(doc));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a checked task renders its observed evidence inline", () => {
|
|
45
|
+
const rendered = renderFeatureDoc(docWithTasks(3));
|
|
46
|
+
assert.match(rendered, /- \[x\] T2\. Task 2\n {2}- observed: `npm test` → success/);
|
|
47
|
+
assert.match(rendered, /- \[ \] T1\. Task 1/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a checked task renders its review candidate, never its checkbox", () => {
|
|
51
|
+
const rendered = renderFeatureDoc(docWithTasks(3));
|
|
52
|
+
assert.match(rendered, /- \[x\] T2\. Task 2\n {2}- observed: `npm test` → success\n {2}- candidate: 1a2b3c4/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a checked task without a candidate parses as a defect, not as checked", () => {
|
|
56
|
+
const text = renderFeatureDoc(docWithTasks(3)).replace(" - candidate: 1a2b3c4\n", "");
|
|
57
|
+
const parsed = parseFeatureDoc(text);
|
|
58
|
+
assert.ok(
|
|
59
|
+
parsed.defects.some((d) => d.includes("T2") && d.includes("candidate")),
|
|
60
|
+
`defects should name the missing candidate: ${JSON.stringify(parsed.defects)}`,
|
|
61
|
+
);
|
|
62
|
+
assert.equal(parsed.doc.tasks[1].checked, false, "a checkoff NODD cannot attribute is not a checkoff");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a missing section returns a typed defect list, never a throw", () => {
|
|
66
|
+
const broken = "# Feature: Demo\n\n## Objective\n\nx\n";
|
|
67
|
+
const parsed = parseFeatureDoc(broken);
|
|
68
|
+
assert.ok(parsed.defects.length > 0);
|
|
69
|
+
for (const missing of ["## Problem", "## Scope", "## Constraints", "## Route", "## Tasks"]) {
|
|
70
|
+
assert.ok(
|
|
71
|
+
parsed.defects.some((d) => d.includes(missing)),
|
|
72
|
+
`defects should name ${missing}: ${JSON.stringify(parsed.defects)}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
assert.equal(parsed.doc.objective, "x");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the slug survives the round trip", () => {
|
|
79
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(docWithTasks(1)));
|
|
80
|
+
assert.equal(parsed.doc.slug, "demo");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// The declared verification contract (`## Verification`)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
test("the declared runner and TDD mode survive the round trip", () => {
|
|
87
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(docWithTasks(1)));
|
|
88
|
+
assert.equal(parsed.doc.verification.runner, "npm test");
|
|
89
|
+
assert.equal(parsed.doc.verification.source, "nodd_declare");
|
|
90
|
+
assert.deepEqual(parsed.doc.verification.files, ["src/thing.ts"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("an undeclared runner round-trips as null, never as an invented command", () => {
|
|
94
|
+
const doc = emptyDoc({ slug: "bare", title: "Bare" });
|
|
95
|
+
const parsed = parseFeatureDoc(renderFeatureDoc(doc));
|
|
96
|
+
assert.equal(parsed.doc.verification.runner, null);
|
|
97
|
+
assert.equal(parsed.doc.verification.tdd, "off");
|
|
98
|
+
assert.deepEqual(parsed.doc.verification.files, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Matrix row 13: a pending check cannot be dropped from the close report
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
test("the Outcome section is derived from the tasks, so a pending check cannot be omitted", () => {
|
|
105
|
+
const rendered = renderFeatureDoc(docWithTasks(3));
|
|
106
|
+
const outcome = rendered.split("## Outcome")[1].split("## Progress")[0];
|
|
107
|
+
|
|
108
|
+
assert.match(outcome, /verified: 1 of 3/, "the count is reported, not asserted");
|
|
109
|
+
assert.match(outcome, /\[x\] T2: `npm test` → success/, "a verified task carries its observed evidence");
|
|
110
|
+
for (const pending of ["T1", "T3"]) {
|
|
111
|
+
assert.match(outcome, new RegExp(`\\[ \\] ${pending}: no observed verification yet`), `${pending} must appear as pending`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("no caller can author the Outcome section: it is a function of the task list", () => {
|
|
116
|
+
const doc = docWithTasks(3);
|
|
117
|
+
assert.ok(!("outcome" in doc), "there is no free-text outcome field to forge");
|
|
118
|
+
assert.deepEqual(renderOutcome(doc), renderOutcome({ ...doc }));
|
|
119
|
+
assert.deepEqual(renderOutcome(emptyDoc({ slug: "s", title: "T" })), ["No tasks declared yet."]);
|
|
120
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// `.nodd/<slug>/feature.md` — extension-owned, never authored freehand by the
|
|
2
|
+
// model. The schema follows gentle's `odd/tasks/<feature>.md`
|
|
3
|
+
// (`odd/tasks/odd-mandatory-delegation.md`): title, Objective, Problem, Scope,
|
|
4
|
+
// Constraints, Route, Tasks, Outcome, Progress.
|
|
5
|
+
//
|
|
6
|
+
// Two properties make this file evidence rather than prose: a checked task
|
|
7
|
+
// carries its evidence reference *by type*, so rendering a `[x]` without one
|
|
8
|
+
// does not compile; and a missing section returns a defect list instead of
|
|
9
|
+
// throwing, because a half-written doc must be reported, not crash a session.
|
|
10
|
+
|
|
11
|
+
import { defaultDelivery, parseDelivery, renderDelivery, type Delivery } from "./delivery.ts";
|
|
12
|
+
|
|
13
|
+
export type Intent = "read-only" | "change";
|
|
14
|
+
export type Route = "inline" | "tracked" | "forge";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How this feature is verified, fixed at declaration time. `gate-evidence` will
|
|
18
|
+
* only accept a run of `runner`, so a checkoff cannot be satisfied by whatever
|
|
19
|
+
* exit-0 string the model happens to produce. `source` records who decided, per
|
|
20
|
+
* `routing.go:101`: the presence of a framework is not a choice.
|
|
21
|
+
*/
|
|
22
|
+
export type Verification = {
|
|
23
|
+
runner: string | null;
|
|
24
|
+
tdd: "strict" | "off";
|
|
25
|
+
source: string;
|
|
26
|
+
/** The distinct files the declaration promised to touch. */
|
|
27
|
+
files: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TaskEvidence = {
|
|
31
|
+
command: string;
|
|
32
|
+
/** Rendered verbatim. `src/outcome.ts` owns the vocabulary. */
|
|
33
|
+
outcome: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A checked task carries both its observed evidence and its review candidate,
|
|
38
|
+
* by type. The candidate is a commit SHA or the literal `pending-commit`
|
|
39
|
+
* (`src/review-candidate.ts`) — never the checkbox, per `routing.go:51`,`:102`.
|
|
40
|
+
*/
|
|
41
|
+
export type Task =
|
|
42
|
+
| { id: string; title: string; checked: false }
|
|
43
|
+
| { id: string; title: string; checked: true; evidence: TaskEvidence; candidate: string };
|
|
44
|
+
|
|
45
|
+
export type FeatureDoc = {
|
|
46
|
+
slug: string;
|
|
47
|
+
title: string;
|
|
48
|
+
objective: string;
|
|
49
|
+
problem: string;
|
|
50
|
+
scope: string;
|
|
51
|
+
constraints: string;
|
|
52
|
+
route: { intent: Intent; route: Route };
|
|
53
|
+
verification: Verification;
|
|
54
|
+
/** Recorded and measured, never enforced (`src/delivery.ts`). */
|
|
55
|
+
delivery: Delivery;
|
|
56
|
+
tasks: Task[];
|
|
57
|
+
progress: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ParsedFeatureDoc = { doc: FeatureDoc; defects: string[] };
|
|
61
|
+
|
|
62
|
+
export function emptyDoc(fields: { slug: string; title: string }): FeatureDoc {
|
|
63
|
+
return {
|
|
64
|
+
slug: fields.slug,
|
|
65
|
+
title: fields.title,
|
|
66
|
+
objective: "",
|
|
67
|
+
problem: "",
|
|
68
|
+
scope: "",
|
|
69
|
+
constraints: "",
|
|
70
|
+
route: { intent: "change", route: "inline" },
|
|
71
|
+
verification: { runner: null, tdd: "off", source: "undeclared", files: [] },
|
|
72
|
+
delivery: defaultDelivery(),
|
|
73
|
+
tasks: [],
|
|
74
|
+
progress: "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `## Outcome`, rendered from the task list rather than stored.
|
|
80
|
+
*
|
|
81
|
+
* Matrix row 13 (`routing.go:51`) requires that a close report cannot drop a
|
|
82
|
+
* failed or pending check. A free-text field can: in round 1 this was a string
|
|
83
|
+
* nothing ever wrote, so the section was permanently empty while the matrix
|
|
84
|
+
* claimed it was "rendered from the ledger". Deriving it makes the claim true —
|
|
85
|
+
* a pending task is in this section because it is in the list, and no caller
|
|
86
|
+
* can omit it.
|
|
87
|
+
*/
|
|
88
|
+
export function renderOutcome(doc: FeatureDoc): string[] {
|
|
89
|
+
if (doc.tasks.length === 0) return ["No tasks declared yet."];
|
|
90
|
+
|
|
91
|
+
const done = doc.tasks.filter((task) => task.checked);
|
|
92
|
+
const pending = doc.tasks.filter((task) => !task.checked);
|
|
93
|
+
const lines = [`- verified: ${done.length} of ${doc.tasks.length} task(s)`];
|
|
94
|
+
|
|
95
|
+
for (const task of done) {
|
|
96
|
+
if (task.checked) lines.push(`- [x] ${task.id}: \`${task.evidence.command}\` → ${task.evidence.outcome}`);
|
|
97
|
+
}
|
|
98
|
+
for (const task of pending) {
|
|
99
|
+
lines.push(`- [ ] ${task.id}: no observed verification yet`);
|
|
100
|
+
}
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SECTIONS = [
|
|
105
|
+
"Objective",
|
|
106
|
+
"Problem",
|
|
107
|
+
"Scope",
|
|
108
|
+
"Constraints",
|
|
109
|
+
"Route",
|
|
110
|
+
"Verification",
|
|
111
|
+
"Delivery",
|
|
112
|
+
"Tasks",
|
|
113
|
+
"Outcome",
|
|
114
|
+
"Progress",
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
function renderVerification(v: Verification): string[] {
|
|
118
|
+
return [
|
|
119
|
+
`- runner: ${v.runner ?? "none declared"}`,
|
|
120
|
+
`- tdd: ${v.tdd}`,
|
|
121
|
+
`- source: ${v.source}`,
|
|
122
|
+
`- files: ${v.files.length > 0 ? v.files.join(", ") : "none declared"}`,
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseVerification(block: string): Verification {
|
|
127
|
+
const field = (name: string): string | null => {
|
|
128
|
+
const match = new RegExp(`^- ${name}: (.+)$`, "m").exec(block);
|
|
129
|
+
const value = match ? match[1].trim() : null;
|
|
130
|
+
return value === null || value === "none declared" ? null : value;
|
|
131
|
+
};
|
|
132
|
+
const files = field("files");
|
|
133
|
+
return {
|
|
134
|
+
runner: field("runner"),
|
|
135
|
+
tdd: field("tdd") === "strict" ? "strict" : "off",
|
|
136
|
+
source: field("source") ?? "undeclared",
|
|
137
|
+
files: files === null ? [] : files.split(",").map((part) => part.trim()).filter((part) => part !== ""),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderTask(task: Task): string {
|
|
142
|
+
const box = task.checked ? "x" : " ";
|
|
143
|
+
const head = `- [${box}] ${task.id}. ${task.title}`;
|
|
144
|
+
if (!task.checked) return head;
|
|
145
|
+
return [
|
|
146
|
+
head,
|
|
147
|
+
` - observed: \`${task.evidence.command}\` → ${task.evidence.outcome}`,
|
|
148
|
+
` - candidate: ${task.candidate}`,
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function renderFeatureDoc(doc: FeatureDoc): string {
|
|
153
|
+
const body = [
|
|
154
|
+
`# Feature: ${doc.title}`,
|
|
155
|
+
"",
|
|
156
|
+
`<!-- nodd:slug ${doc.slug} -->`,
|
|
157
|
+
"",
|
|
158
|
+
"## Objective",
|
|
159
|
+
"",
|
|
160
|
+
doc.objective,
|
|
161
|
+
"",
|
|
162
|
+
"## Problem",
|
|
163
|
+
"",
|
|
164
|
+
doc.problem,
|
|
165
|
+
"",
|
|
166
|
+
"## Scope",
|
|
167
|
+
"",
|
|
168
|
+
doc.scope,
|
|
169
|
+
"",
|
|
170
|
+
"## Constraints",
|
|
171
|
+
"",
|
|
172
|
+
doc.constraints,
|
|
173
|
+
"",
|
|
174
|
+
"## Route",
|
|
175
|
+
"",
|
|
176
|
+
`- intent: ${doc.route.intent}`,
|
|
177
|
+
`- route: ${doc.route.route}`,
|
|
178
|
+
"",
|
|
179
|
+
"## Verification",
|
|
180
|
+
"",
|
|
181
|
+
...renderVerification(doc.verification),
|
|
182
|
+
"",
|
|
183
|
+
"## Delivery",
|
|
184
|
+
"",
|
|
185
|
+
...renderDelivery(doc.delivery),
|
|
186
|
+
"",
|
|
187
|
+
"## Tasks",
|
|
188
|
+
"",
|
|
189
|
+
doc.tasks.map(renderTask).join("\n"),
|
|
190
|
+
"",
|
|
191
|
+
"## Outcome",
|
|
192
|
+
"",
|
|
193
|
+
...renderOutcome(doc),
|
|
194
|
+
"",
|
|
195
|
+
"## Progress",
|
|
196
|
+
"",
|
|
197
|
+
doc.progress,
|
|
198
|
+
"",
|
|
199
|
+
];
|
|
200
|
+
return body.join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Split the body into `## <name>` sections, preserving each block's text. */
|
|
204
|
+
function splitSections(text: string): Map<string, string> {
|
|
205
|
+
const out = new Map<string, string>();
|
|
206
|
+
let current: string | null = null;
|
|
207
|
+
let buffer: string[] = [];
|
|
208
|
+
for (const line of text.split("\n")) {
|
|
209
|
+
const heading = /^## (.+)$/.exec(line);
|
|
210
|
+
if (heading) {
|
|
211
|
+
if (current) out.set(current, buffer.join("\n").trim());
|
|
212
|
+
current = heading[1].trim();
|
|
213
|
+
buffer = [];
|
|
214
|
+
} else if (current) {
|
|
215
|
+
buffer.push(line);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (current) out.set(current, buffer.join("\n").trim());
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseTasks(block: string, defects: string[]): Task[] {
|
|
223
|
+
const tasks: Task[] = [];
|
|
224
|
+
const lines = block.split("\n");
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
const item = /^- \[( |x)\] (\S+)\. (.*)$/.exec(lines[i]);
|
|
227
|
+
if (!item) continue;
|
|
228
|
+
const [, box, id, title] = item;
|
|
229
|
+
if (box !== "x") {
|
|
230
|
+
tasks.push({ id, title, checked: false });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const evidence = /^ {2}- observed: `(.*)` → (.+)$/.exec(lines[i + 1] ?? "");
|
|
234
|
+
if (!evidence) {
|
|
235
|
+
defects.push(`task ${id} is checked but carries no observed evidence line`);
|
|
236
|
+
tasks.push({ id, title, checked: false });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const candidate = /^ {2}- candidate: (.+)$/.exec(lines[i + 2] ?? "");
|
|
240
|
+
if (!candidate) {
|
|
241
|
+
defects.push(`task ${id} is checked but carries no review candidate line`);
|
|
242
|
+
tasks.push({ id, title, checked: false });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
i += 2;
|
|
246
|
+
tasks.push({
|
|
247
|
+
id,
|
|
248
|
+
title,
|
|
249
|
+
checked: true,
|
|
250
|
+
evidence: { command: evidence[1], outcome: evidence[2] },
|
|
251
|
+
candidate: candidate[1].trim(),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return tasks;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function parseFeatureDoc(text: string): ParsedFeatureDoc {
|
|
258
|
+
const defects: string[] = [];
|
|
259
|
+
const title = /^# Feature: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
|
260
|
+
if (!title) defects.push("missing the `# Feature: <title>` heading");
|
|
261
|
+
const slug = /<!-- nodd:slug (\S+) -->/.exec(text)?.[1] ?? "";
|
|
262
|
+
if (!slug) defects.push("missing the `nodd:slug` marker");
|
|
263
|
+
|
|
264
|
+
const sections = splitSections(text);
|
|
265
|
+
for (const name of SECTIONS) {
|
|
266
|
+
if (!sections.has(name)) defects.push(`missing section \`## ${name}\``);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const routeBlock = sections.get("Route") ?? "";
|
|
270
|
+
const intent = /- intent: (\S+)/.exec(routeBlock)?.[1];
|
|
271
|
+
const route = /- route: (\S+)/.exec(routeBlock)?.[1];
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
doc: {
|
|
275
|
+
slug,
|
|
276
|
+
title,
|
|
277
|
+
objective: sections.get("Objective") ?? "",
|
|
278
|
+
problem: sections.get("Problem") ?? "",
|
|
279
|
+
scope: sections.get("Scope") ?? "",
|
|
280
|
+
constraints: sections.get("Constraints") ?? "",
|
|
281
|
+
route: {
|
|
282
|
+
intent: intent === "read-only" || intent === "change" ? intent : "change",
|
|
283
|
+
route: route === "tracked" || route === "forge" ? route : "inline",
|
|
284
|
+
},
|
|
285
|
+
verification: parseVerification(sections.get("Verification") ?? ""),
|
|
286
|
+
delivery: parseDelivery(sections.get("Delivery") ?? ""),
|
|
287
|
+
tasks: parseTasks(sections.get("Tasks") ?? "", defects),
|
|
288
|
+
progress: sections.get("Progress") ?? "",
|
|
289
|
+
},
|
|
290
|
+
defects,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { emptyCommitted, fold, type Committed } from "../state.ts";
|
|
4
|
+
import { observation } from "../observations.ts";
|
|
5
|
+
import { emptyPolicy } from "./policy.ts";
|
|
6
|
+
import { authorizeGate } from "./authorize.ts";
|
|
7
|
+
|
|
8
|
+
function declared(intent: "read-only" | "change"): Committed {
|
|
9
|
+
return fold(emptyCommitted(), observation({
|
|
10
|
+
toolCallId: "d1", toolName: "nodd_declare",
|
|
11
|
+
input: { intent, route: "inline", slug: "demo" },
|
|
12
|
+
isError: false, resultText: "", at: "t",
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const write = { toolName: "write", input: { path: "src/a.ts" } };
|
|
17
|
+
|
|
18
|
+
test("read-only blocks a write and the reason names the declaration", () => {
|
|
19
|
+
const decision = authorizeGate(declared("read-only"), write, emptyPolicy());
|
|
20
|
+
assert.equal(decision.allow, false);
|
|
21
|
+
assert.ok(decision.allow === false && decision.reason.includes("read-only"));
|
|
22
|
+
assert.ok(decision.allow === false && decision.remedy.escapeHatch === "/nodd-allow authorize");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("read-only blocks edit, mutating bash and the subagent writer", () => {
|
|
26
|
+
const state = declared("read-only");
|
|
27
|
+
for (const request of [
|
|
28
|
+
{ toolName: "edit", input: { path: "a.ts" } },
|
|
29
|
+
{ toolName: "bash", input: { command: "echo x > f" } },
|
|
30
|
+
{ toolName: "subagent", input: { agent: "nodd-implement", prompt: "write the thing" } },
|
|
31
|
+
]) {
|
|
32
|
+
assert.equal(authorizeGate(state, request, emptyPolicy()).allow, false, `${request.toolName} must block`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("read-only allows reading, searching and non-mutating bash", () => {
|
|
37
|
+
const state = declared("read-only");
|
|
38
|
+
for (const request of [
|
|
39
|
+
{ toolName: "read", input: { path: "a.ts" } },
|
|
40
|
+
{ toolName: "grep", input: { pattern: "x" } },
|
|
41
|
+
{ toolName: "ls", input: { path: "." } },
|
|
42
|
+
{ toolName: "bash", input: { command: "npm test" } },
|
|
43
|
+
{ toolName: "bash", input: { command: "git status" } },
|
|
44
|
+
]) {
|
|
45
|
+
assert.equal(authorizeGate(state, request, emptyPolicy()).allow, true, `${JSON.stringify(request.input)} must pass`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("intent: change allows the write", () => {
|
|
50
|
+
assert.equal(authorizeGate(declared("change"), write, emptyPolicy()).allow, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The two gates are disjoint on purpose: `classify` owns the undeclared case,
|
|
54
|
+
// so a single call never collects two messages about the same missing thing.
|
|
55
|
+
test("an undeclared state returns allow — that is gate-classify's job", () => {
|
|
56
|
+
assert.equal(authorizeGate(emptyCommitted(), write, emptyPolicy()).allow, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the flag off allows the read-only write", () => {
|
|
60
|
+
const policy = { ...emptyPolicy(), config: { authorize: { enabled: false } } };
|
|
61
|
+
assert.equal(authorizeGate(declared("read-only"), write, policy).allow, true);
|
|
62
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// gate-authorize — read-only work stays read-only.
|
|
2
|
+
//
|
|
3
|
+
// `routing.go:43-44`: investigation, explanation, review, audit and planning
|
|
4
|
+
// requests are read-only unless the user explicitly asked for a change; such
|
|
5
|
+
// work "may inspect, explain, compare, and recommend, but must not write or
|
|
6
|
+
// edit files, delegate a writer, invoke apply, or create implementation
|
|
7
|
+
// artifacts". Delegating a writer counts, which is why `subagent` is blocked
|
|
8
|
+
// here too — otherwise read-only would mean "do not write it yourself".
|
|
9
|
+
//
|
|
10
|
+
// This gate is silent when nothing was declared. That case belongs to
|
|
11
|
+
// `gate-classify`, and the two never double-block the same call.
|
|
12
|
+
|
|
13
|
+
import type { Committed } from "../state.ts";
|
|
14
|
+
import { allow, refuse, resolveFlag, type GateDecision, type Policy } from "./policy.ts";
|
|
15
|
+
import { isDelegation, isMutation } from "./request.ts";
|
|
16
|
+
import type { GateRequest } from "./request.ts";
|
|
17
|
+
|
|
18
|
+
export function authorizeGate(committed: Committed, request: GateRequest, policy: Policy): GateDecision {
|
|
19
|
+
if (!resolveFlag("authorize", policy).enabled) return allow();
|
|
20
|
+
|
|
21
|
+
const declaration = committed.declaration;
|
|
22
|
+
if (!declaration || declaration.intent !== "read-only") return allow();
|
|
23
|
+
|
|
24
|
+
if (!isMutation(request) && !isDelegation(request)) return allow();
|
|
25
|
+
|
|
26
|
+
const what = isDelegation(request) ? `delegating to a writer (${request.toolName})` : `${request.toolName}`;
|
|
27
|
+
return refuse(
|
|
28
|
+
"authorize",
|
|
29
|
+
`this request was declared \`intent: read-only\`, and ${what} would change the workspace`,
|
|
30
|
+
"if the user did authorize a change, call `nodd_declare` again with `intent: change`",
|
|
31
|
+
);
|
|
32
|
+
}
|