@gonrocca/nodd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/extensions/nodd-agents.test.ts +129 -0
  4. package/extensions/nodd-agents.ts +185 -0
  5. package/extensions/nodd-allow.test.ts +75 -0
  6. package/extensions/nodd-allow.ts +76 -0
  7. package/extensions/nodd-enforcement.test.ts +676 -0
  8. package/extensions/nodd-gates.test.ts +108 -0
  9. package/extensions/nodd-gates.ts +121 -0
  10. package/extensions/nodd-kernel.test.ts +114 -0
  11. package/extensions/nodd-kernel.ts +593 -0
  12. package/extensions/nodd-models.test.ts +174 -0
  13. package/extensions/nodd-models.ts +253 -0
  14. package/extensions/nodd-promote.test.ts +150 -0
  15. package/extensions/nodd-promote.ts +96 -0
  16. package/extensions/nodd-prompt.test.ts +87 -0
  17. package/extensions/nodd-tools.test.ts +211 -0
  18. package/package.json +44 -0
  19. package/src/bash-classifier.test.ts +114 -0
  20. package/src/bash-classifier.ts +69 -0
  21. package/src/change-acceptance.test.ts +175 -0
  22. package/src/change-acceptance.ts +98 -0
  23. package/src/config.test.ts +61 -0
  24. package/src/config.ts +103 -0
  25. package/src/delivery.test.ts +156 -0
  26. package/src/delivery.ts +151 -0
  27. package/src/feature-doc.test.ts +120 -0
  28. package/src/feature-doc.ts +292 -0
  29. package/src/gates/authorize.test.ts +62 -0
  30. package/src/gates/authorize.ts +32 -0
  31. package/src/gates/classify.test.ts +54 -0
  32. package/src/gates/classify.ts +45 -0
  33. package/src/gates/delegate.test.ts +127 -0
  34. package/src/gates/delegate.ts +85 -0
  35. package/src/gates/evidence.test.ts +281 -0
  36. package/src/gates/evidence.ts +209 -0
  37. package/src/gates/policy.test.ts +77 -0
  38. package/src/gates/policy.ts +90 -0
  39. package/src/gates/promotion.test.ts +133 -0
  40. package/src/gates/promotion.ts +81 -0
  41. package/src/gates/registry.ts +21 -0
  42. package/src/gates/request.ts +41 -0
  43. package/src/gates/track.test.ts +80 -0
  44. package/src/gates/track.ts +58 -0
  45. package/src/io.test.ts +81 -0
  46. package/src/io.ts +94 -0
  47. package/src/ledger.test.ts +122 -0
  48. package/src/ledger.ts +133 -0
  49. package/src/manifest.test.ts +53 -0
  50. package/src/manifest.ts +61 -0
  51. package/src/models/assign.test.ts +125 -0
  52. package/src/models/assign.ts +138 -0
  53. package/src/models/picker.test.ts +141 -0
  54. package/src/models/picker.ts +98 -0
  55. package/src/models/profiles.test.ts +186 -0
  56. package/src/models/profiles.ts +162 -0
  57. package/src/models/slots.ts +48 -0
  58. package/src/observations.test.ts +61 -0
  59. package/src/observations.ts +51 -0
  60. package/src/odd-prose.test.ts +125 -0
  61. package/src/odd-prose.ts +198 -0
  62. package/src/outcome.test.ts +75 -0
  63. package/src/outcome.ts +63 -0
  64. package/src/promote.test.ts +129 -0
  65. package/src/promote.ts +64 -0
  66. package/src/prompt.test.ts +193 -0
  67. package/src/prompt.ts +136 -0
  68. package/src/review-candidate.test.ts +118 -0
  69. package/src/review-candidate.ts +81 -0
  70. package/src/state.test.ts +153 -0
  71. package/src/state.ts +163 -0
  72. package/test/package-invariants.test.ts +66 -0
  73. package/test/parity-matrix.test.ts +272 -0
  74. package/test/readme-contract.test.ts +182 -0
@@ -0,0 +1,198 @@
1
+ // ODD's genuinely unmechanizable clauses — kept as guidance, labelled as guidance.
2
+ //
3
+ // One entry per `(P)` row of `REQ: odd-parity-matrix`, tagged with the canonical
4
+ // step it belongs to and carrying the reason it could not become a gate. That
5
+ // reason is load-bearing: the matrix forbids "we did not get to it", because a
6
+ // clause with no stated obstacle is a mechanism someone skipped, and a test here
7
+ // rejects that wording.
8
+ //
9
+ // **Why tag by step.** `src/prompt.ts` forwards only the current step's entries.
10
+ // Injecting all eighteen at every turn is how gentle's guidance wall reached
11
+ // 105,993 bytes: every clause was individually defensible. Scoping by step keeps
12
+ // the block inside its budget without anyone having to delete anything.
13
+ //
14
+ // This module is data, not behaviour. It reads no file, imports no gate, and is
15
+ // frozen so no caller can append to it at runtime.
16
+
17
+ import type { CanonicalStep } from "./manifest.ts";
18
+
19
+ export type ProseEntry = {
20
+ /** Row number in `REQ: odd-parity-matrix`. */
21
+ row: number;
22
+ /** The `routing.go` line, as the matrix cites it. */
23
+ line: string;
24
+ step: CanonicalStep;
25
+ /** The clause as forwarded to the model. */
26
+ clause: string;
27
+ /** Why it is not mechanizable. */
28
+ reason: string;
29
+ };
30
+
31
+ export const ODD_PROSE: readonly ProseEntry[] = Object.freeze([
32
+ {
33
+ row: 4,
34
+ line: ":45",
35
+ step: "authorize",
36
+ clause:
37
+ "If change intent is ambiguous, ask one clarifying question and stay read-only until it is answered.",
38
+ reason:
39
+ "Whether a request is ambiguous is a judgement about natural language. NODD mechanizes the consequence — undeclared intent blocks the first write — but the asking itself cannot be derived from tool events.",
40
+ },
41
+ {
42
+ row: 5,
43
+ line: ":46",
44
+ step: "explore",
45
+ clause:
46
+ "Explore the existing code and requirements first, proportionate to the request.",
47
+ reason:
48
+ "Proportionality is a judgement. The delegation counters mechanize the excess, never the sufficiency: NODD can see that too many files were read, never that enough were.",
49
+ },
50
+ {
51
+ row: 6,
52
+ line: ":47",
53
+ step: "resolve-uncertainty",
54
+ clause:
55
+ "Research is optional and scoped to one named uncertainty: ask at most one focused question and make at most one assumption challenge.",
56
+ reason:
57
+ "Whether a premise is high-consequence and unproven is not observable from tool events; only the model reading the problem can tell.",
58
+ },
59
+ {
60
+ row: 7,
61
+ line: ":48",
62
+ step: "classify",
63
+ clause:
64
+ "Substantial work means two or more meaningful steps, or progress worth recovering after an interruption. Small work stays small.",
65
+ reason:
66
+ "This is ODD's own admitted hole: the judgement is not mechanizable. NODD mechanizes only the declaration, so a skipped classification is observable instead of silent.",
67
+ },
68
+ {
69
+ row: 11,
70
+ line: ":50",
71
+ step: "implement",
72
+ clause:
73
+ "Close every task with a work-unit commit using a Conventional Commit message, branching first when you are on the default branch.",
74
+ reason:
75
+ "NODD observes the commit and records its SHA, but the wording of a Conventional Commit message is prose, and branch/PR composition belongs to the delivery tools.",
76
+ },
77
+ {
78
+ row: 19,
79
+ line: ":63",
80
+ step: "explore",
81
+ clause:
82
+ "Reading that prepares a write, and broad research, are delegated too — not only reading that stands on its own.",
83
+ reason:
84
+ "Intent-to-write is not visible in a read event. NODD cannot tell a read that prepares an edit from a read that answers a question, so the trigger stays advice.",
85
+ },
86
+ {
87
+ row: 28,
88
+ line: ":81",
89
+ step: "explore",
90
+ clause:
91
+ "Preparation trigger: when reading is preparing a change rather than answering a question, delegate before the reading accumulates.",
92
+ reason:
93
+ "Same obstacle as row 19, and the reason NODD ships no `prepare` gate: a mechanism here would have to guess intent, and a gate that guesses is worse than prose that admits it.",
94
+ },
95
+ {
96
+ row: 29,
97
+ line: ":82",
98
+ step: "implement",
99
+ clause:
100
+ "Two or more non-mechanical edits without delegating is a backstop trigger, alongside the tool-call and read counters.",
101
+ reason:
102
+ "Tool calls and distinct reads are counted exactly; whether an edit is non-mechanical is a judgement, so that third leg of the backstop is forwarded rather than counted.",
103
+ },
104
+ {
105
+ row: 32,
106
+ line: ":89",
107
+ step: "resolve-uncertainty",
108
+ clause:
109
+ "If research is declined, continue only where it is safe to do so, and disclose the uncertainty that stayed unresolved.",
110
+ reason:
111
+ "Safety under missing evidence is a judgement about consequences, not an observable property of a session.",
112
+ },
113
+ {
114
+ row: 33,
115
+ line: ":90",
116
+ step: "explore",
117
+ clause:
118
+ "Establish the problem, the outcome, the constraints and the evidence, adapting depth to consequence. The parent owns product decisions; workers return gaps instead of deciding.",
119
+ reason:
120
+ "The feature doc enforces the structure; how deep each section should go for a given consequence is prose.",
121
+ },
122
+ {
123
+ row: 34,
124
+ line: ":91",
125
+ step: "resolve-uncertainty",
126
+ clause:
127
+ "Prefer primary sources, attribute every claim to a URL or a code location, and distinguish verified facts from assumptions.",
128
+ reason:
129
+ "Citation quality is not observable from tool events: NODD can see that a file was read, never whether the claim it supports is faithful to it.",
130
+ },
131
+ {
132
+ row: 35,
133
+ line: ":92",
134
+ step: "resolve-uncertainty",
135
+ clause:
136
+ "Return findings, a recommendation, the tradeoffs and the open questions; forward research instructions to a fresh worker.",
137
+ reason:
138
+ "The report format is prose. The authority half is mechanized: an observed subagent result never sets intent — only an explicit declaration does.",
139
+ },
140
+ {
141
+ row: 36,
142
+ line: ":93",
143
+ step: "resolve-uncertainty",
144
+ clause:
145
+ "Make at most one scoped assumption challenge, naming the premise, the evidence and the consequence. Do not open a debate loop.",
146
+ reason:
147
+ "Neither the scope of a challenge nor the onset of a debate loop is observable from tool events.",
148
+ },
149
+ {
150
+ row: 38,
151
+ line: ":95",
152
+ step: "implement",
153
+ clause:
154
+ "About 400 authored changed lines per task is a planning heuristic only. It is not an acceptance criterion, not a hard cap, not a counter-trigger, not an automatic stop, not a forced split and not a review trigger. If the correct solution naturally exceeds it, say so briefly and continue.",
155
+ reason:
156
+ "A line count measures typing, not correctness. Mechanizing it would make the number an objective to optimize against, which is precisely what the clause forbids.",
157
+ },
158
+ {
159
+ row: 39,
160
+ line: ":95",
161
+ step: "implement",
162
+ clause:
163
+ "Never delete blank lines or comments for cosmetic savings, never minify, never omit tests, never add gratuitous abstractions, and never split work artificially to fit the number. Forward this same advisory to every subagent.",
164
+ reason:
165
+ "This is the anti-gaming half of the heuristic, and it only works as prose: any mechanism enforcing it would itself become the thing to game.",
166
+ },
167
+ {
168
+ row: 41,
169
+ line: ":97",
170
+ step: "track",
171
+ clause:
172
+ "Business scope changes still require the user's authorization, even when a finding makes the change look obviously correct.",
173
+ reason:
174
+ "Whether a change is a business scope change is a product judgement. The preservation, reason-required and checkbox halves of this clause are mechanized; this half is not.",
175
+ },
176
+ {
177
+ row: 44,
178
+ line: ":100",
179
+ step: "track",
180
+ clause:
181
+ "Read the feature document and its observation, then pass the locator to every worker. Small work without a doc still receives its authorized scope and its checks.",
182
+ reason:
183
+ "Handoff discipline lives in the prompt the parent writes for the worker, which NODD does not author.",
184
+ },
185
+ {
186
+ row: 49,
187
+ line: ":103",
188
+ step: "close",
189
+ clause:
190
+ "Ask once for the delivery strategy when the work is risky, rather than choosing chaining and slice boundaries silently.",
191
+ reason:
192
+ "The asking is the un-mechanized part: NODD records the strategy and measures the forecast, but deciding that a given change warrants asking is a judgement.",
193
+ },
194
+ ]);
195
+
196
+ export function proseForStep(step: CanonicalStep): readonly ProseEntry[] {
197
+ return ODD_PROSE.filter((entry) => entry.step === step);
198
+ }
@@ -0,0 +1,75 @@
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 { parseOutcome, isSuccess, type Outcome } from "./outcome.ts";
7
+
8
+ test("isError false is success by construction — pi's bash throws on any non-zero code", () => {
9
+ assert.deepEqual(parseOutcome(false, "anything at all"), { kind: "success" });
10
+ assert.deepEqual(parseOutcome(false, ""), { kind: "success" });
11
+ });
12
+
13
+ const TABLE: Array<[string, Outcome]> = [
14
+ ["Command exited with code 1", { kind: "exit", code: 1 }],
15
+ ["Command exited with code 137", { kind: "exit", code: 137 }],
16
+ ["Command exited with code 0", { kind: "exit", code: 0 }],
17
+ ["Command aborted", { kind: "aborted" }],
18
+ ["Command timed out after 120 seconds", { kind: "timeout", seconds: 120 }],
19
+ ["Command timed out after 5 seconds", { kind: "timeout", seconds: 5 }],
20
+ ];
21
+
22
+ test("the five outcomes parse from the last line appended by bash.js:321", () => {
23
+ for (const [line, expected] of TABLE) {
24
+ assert.deepEqual(parseOutcome(true, line), expected, `"${line}"`);
25
+ }
26
+ });
27
+
28
+ test("a non-matching error text is unknown, never success", () => {
29
+ for (const text of ["", "ENOENT: no such file", "killed", "Command exited with code abc"]) {
30
+ const outcome = parseOutcome(true, text);
31
+ assert.equal(outcome.kind, "unknown", `"${text}" must be unknown`);
32
+ assert.equal(isSuccess(outcome), false, "unknown must never count as success");
33
+ }
34
+ });
35
+
36
+ // The marker is the *last* line pi appends. Text that merely mentions it is
37
+ // stdout, and stdout is the model's playground: a test runner printing
38
+ // "Command exited with code 1" in its own output must not be able to change
39
+ // how its own run is recorded.
40
+ test("the marker is only read from the last non-empty line", () => {
41
+ const stdout = "running suite\nCommand exited with code 1\nall good\n";
42
+ assert.deepEqual(parseOutcome(false, stdout), { kind: "success" },
43
+ "a successful run whose stdout quotes the marker is still success");
44
+
45
+ assert.deepEqual(parseOutcome(true, `${stdout}Command exited with code 2`), { kind: "exit", code: 2 },
46
+ "the real last line wins over an earlier quoted one");
47
+
48
+ assert.deepEqual(parseOutcome(true, "Command aborted\n\n \n"), { kind: "aborted" },
49
+ "trailing blank lines are skipped");
50
+ });
51
+
52
+ test("isSuccess is true for success alone", () => {
53
+ assert.equal(isSuccess({ kind: "success" }), true);
54
+ for (const outcome of [
55
+ { kind: "exit", code: 0 }, { kind: "exit", code: 1 },
56
+ { kind: "aborted" }, { kind: "timeout", seconds: 1 }, { kind: "unknown" },
57
+ ] as Outcome[]) {
58
+ assert.equal(isSuccess(outcome), false, `${outcome.kind} is not success`);
59
+ }
60
+ });
61
+
62
+ // Three distinct failure modes collapsed into one boolean is how "the tests
63
+ // pass" gets written after a timeout. The union has no room for that field.
64
+ test("no boolean verdict field exists on any outcome", () => {
65
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "outcome.ts"), "utf8");
66
+ const type = src.slice(src.indexOf("export type Outcome"), src.indexOf(";", src.indexOf("export type Outcome")));
67
+ for (const field of ["ok", "passed", "success:", "failed"]) {
68
+ assert.ok(!type.includes(field), `the Outcome union must not carry "${field}"`);
69
+ }
70
+ for (const outcome of TABLE.map(([, o]) => o)) {
71
+ for (const field of ["ok", "passed", "success", "failed"]) {
72
+ assert.ok(!(field in outcome), `${outcome.kind} must not carry "${field}"`);
73
+ }
74
+ }
75
+ });
package/src/outcome.ts ADDED
@@ -0,0 +1,63 @@
1
+ // What actually happened when a command ran.
2
+ //
3
+ // Derived from the raw observation, never from a model claim. The source of
4
+ // truth is pi's own bash tool: it throws for every non-zero exit code
5
+ // (`bash.js:347`), so `isError === false` means exit 0 by construction — no
6
+ // wrapper, no `; echo $?`, no tool override. When it does throw, `bash.js:321`
7
+ // appends one marker line describing why.
8
+ //
9
+ // The union deliberately has no boolean. "Did it pass?" collapses `exit 1`,
10
+ // `aborted` and `timeout` into one shape, and that collapse is precisely how a
11
+ // timed-out suite gets written down as "tests pass". Callers must name the
12
+ // failure mode they are willing to accept; `unknown` is never one of them.
13
+
14
+ export type Outcome =
15
+ | { kind: "success" }
16
+ | { kind: "exit"; code: number }
17
+ | { kind: "aborted" }
18
+ | { kind: "timeout"; seconds: number }
19
+ | { kind: "unknown" };
20
+
21
+ function lastNonEmptyLine(text: string): string {
22
+ const lines = (text ?? "").split("\n");
23
+ for (let i = lines.length - 1; i >= 0; i--) {
24
+ const line = lines[i].trim();
25
+ if (line !== "") return line;
26
+ }
27
+ return "";
28
+ }
29
+
30
+ export function parseOutcome(isError: boolean, resultText: string): Outcome {
31
+ if (!isError) return { kind: "success" };
32
+
33
+ // Only the last line. Anything earlier is stdout, and stdout is attacker-
34
+ // controlled in the only sense that matters here: a runner can print the
35
+ // marker text itself, and must not thereby rewrite its own verdict.
36
+ const line = lastNonEmptyLine(resultText);
37
+
38
+ const exited = /^Command exited with code (\d+)$/.exec(line);
39
+ if (exited) return { kind: "exit", code: Number(exited[1]) };
40
+
41
+ if (line === "Command aborted") return { kind: "aborted" };
42
+
43
+ const timedOut = /^Command timed out after (\d+) seconds?$/.exec(line);
44
+ if (timedOut) return { kind: "timeout", seconds: Number(timedOut[1]) };
45
+
46
+ return { kind: "unknown" };
47
+ }
48
+
49
+ /** Only `success` satisfies evidence. Everything else, including `unknown`, does not. */
50
+ export function isSuccess(outcome: Outcome): boolean {
51
+ return outcome.kind === "success";
52
+ }
53
+
54
+ /** A short human phrase for a feature doc or a refusal message. */
55
+ export function describeOutcome(outcome: Outcome): string {
56
+ switch (outcome.kind) {
57
+ case "success": return "success";
58
+ case "exit": return `exit ${outcome.code}`;
59
+ case "aborted": return "aborted";
60
+ case "timeout": return `timed out after ${outcome.seconds}s`;
61
+ case "unknown": return "unknown";
62
+ }
63
+ }
@@ -0,0 +1,129 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { emptyDoc, type FeatureDoc } from "./feature-doc.ts";
5
+ import { promotedRequirements } from "./promote.ts";
6
+
7
+ function doc(overrides: Partial<FeatureDoc> = {}): FeatureDoc {
8
+ return {
9
+ ...emptyDoc({ slug: "cache-warmup", title: "Warm the cache on boot" }),
10
+ objective: "Serve the first request from a warm cache.",
11
+ problem: "Cold starts take 4s because the cache is empty until the first miss.",
12
+ scope: "The boot path only. Not the eviction policy.",
13
+ constraints: "No new dependencies. Boot must stay under 1s.",
14
+ tasks: [
15
+ {
16
+ id: "T001",
17
+ title: "Read the boot sequence",
18
+ checked: true,
19
+ evidence: { command: "node --test src/boot.test.ts", outcome: "success" },
20
+ candidate: "a1b2c3d",
21
+ },
22
+ {
23
+ id: "T002",
24
+ title: "Preload the ten hottest keys",
25
+ checked: true,
26
+ evidence: { command: "node --test src/cache.test.ts", outcome: "success" },
27
+ candidate: "pending-commit",
28
+ },
29
+ { id: "T003", title: "Measure the cold-start delta", checked: false },
30
+ ],
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The content that must survive the handoff
37
+ // ---------------------------------------------------------------------------
38
+ test("the objective, problem, scope and constraints all reach the requirements", () => {
39
+ const out = promotedRequirements(doc());
40
+ assert.ok(out.includes("Serve the first request from a warm cache."));
41
+ assert.ok(out.includes("Cold starts take 4s"));
42
+ assert.ok(out.includes("Not the eviction policy."));
43
+ assert.ok(out.includes("Boot must stay under 1s."));
44
+ assert.ok(out.includes("cache-warmup"), "the slug identifies the run");
45
+ assert.ok(out.includes("Warm the cache on boot"));
46
+ });
47
+
48
+ test("completed tasks arrive with their evidence under an already-resolved heading", () => {
49
+ const out = promotedRequirements(doc());
50
+ const heading = out.split("\n").find((line) => /^##+ /.test(line) && /resolved|done|already/i.test(line));
51
+ assert.ok(heading, `an already-resolved section must exist:\n${out}`);
52
+
53
+ const section = out.slice(out.indexOf(heading!));
54
+ for (const fragment of [
55
+ "Read the boot sequence",
56
+ "Preload the ten hottest keys",
57
+ "node --test src/boot.test.ts",
58
+ "node --test src/cache.test.ts",
59
+ "a1b2c3d",
60
+ ]) {
61
+ assert.ok(section.includes(fragment), `the resolved section must carry ${fragment}`);
62
+ }
63
+ });
64
+
65
+ test("the resolved section says in words that this work must not be redone", () => {
66
+ const out = promotedRequirements(doc());
67
+ assert.match(out, /not be redone|do not redo|already done/i);
68
+ });
69
+
70
+ test("unchecked tasks are carried as remaining work, not as resolved", () => {
71
+ const out = promotedRequirements(doc());
72
+ const resolvedAt = out.search(/^##+ .*(resolved|already)/im);
73
+ assert.ok(out.includes("Measure the cold-start delta"));
74
+ assert.ok(
75
+ out.indexOf("Measure the cold-start delta") < resolvedAt || !out.slice(resolvedAt).includes("Measure the cold-start delta"),
76
+ "an unchecked task must not appear as already-resolved",
77
+ );
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // The deliberate omission
82
+ // ---------------------------------------------------------------------------
83
+ test("the output is a requirements document and nothing else", () => {
84
+ const out = promotedRequirements(doc());
85
+ assert.equal(typeof out, "string");
86
+ assert.ok(out.startsWith("#"), "it opens as a markdown document");
87
+ assert.ok(out.endsWith("\n"));
88
+ });
89
+
90
+ test("nothing in this module emits a design or a tasks document", () => {
91
+ // Deliberate: forge's resume lands on `no-plan` and restarts at `plan` only
92
+ // when design.md and tasks.md are absent (orchestrator.md:102-105). A module
93
+ // that could write either would defeat the whole point of promoting.
94
+ const source = readFileSync(new URL("./promote.ts", import.meta.url), "utf8")
95
+ .split("\n")
96
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
97
+ .join("\n");
98
+ assert.ok(!source.includes("design.md"), "no design.md may be produced");
99
+ assert.ok(!source.includes("tasks.md"), "no tasks.md may be produced");
100
+ });
101
+
102
+ test("the module is pure: it opens no file and writes nothing", () => {
103
+ const source = readFileSync(new URL("./promote.ts", import.meta.url), "utf8")
104
+ .split("\n")
105
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
106
+ .join("\n");
107
+ assert.ok(!source.includes("node:fs"), "string in, string out");
108
+ assert.ok(!/writeFile|mkdir/.test(source));
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Degenerate inputs
113
+ // ---------------------------------------------------------------------------
114
+ test("a doc with no completed tasks says so rather than emitting an empty section", () => {
115
+ const out = promotedRequirements(doc({ tasks: [{ id: "T001", title: "Only task", checked: false }] }));
116
+ assert.match(out, /Only task/);
117
+ assert.match(out, /ning|none|no completed|nothing/i);
118
+ });
119
+
120
+ test("an empty doc still produces a well-formed document", () => {
121
+ const out = promotedRequirements(emptyDoc({ slug: "empty", title: "Empty" }));
122
+ assert.ok(out.startsWith("# "));
123
+ assert.ok(out.includes("empty"));
124
+ assert.doesNotThrow(() => promotedRequirements(emptyDoc({ slug: "x", title: "" })));
125
+ });
126
+
127
+ test("promotion is deterministic: the same doc yields the same bytes", () => {
128
+ assert.equal(promotedRequirements(doc()), promotedRequirements(doc()));
129
+ });
package/src/promote.ts ADDED
@@ -0,0 +1,64 @@
1
+ // Feature doc → forge requirements, as a pure string transform.
2
+ //
3
+ // Promotion is a handoff, not a migration: NODD's `.nodd/<slug>/feature.md` stays
4
+ // where it is, and forge receives a `requirements.md` it can plan from.
5
+ //
6
+ // **What this deliberately does not emit.** No `design.md` and no `tasks.md`.
7
+ // Forge's resume algorithm lands on `no-plan` and restarts at `plan` precisely
8
+ // when those two are absent (`orchestrator.md:102-105`); emitting a half-built
9
+ // plan would push the resume into `analyzing` or `building` and hand forge a
10
+ // plan nobody wrote. A source-scan test asserts neither filename appears here.
11
+ //
12
+ // Completed tasks travel with their observed evidence, under a heading that says
13
+ // in words they must not be redone — a plan phase that re-plans finished work is
14
+ // the expensive failure this section exists to prevent.
15
+
16
+ import type { FeatureDoc, Task } from "./feature-doc.ts";
17
+
18
+ function section(heading: string, body: string, fallback: string): string {
19
+ return `## ${heading}\n\n${body.trim() === "" ? fallback : body.trim()}\n`;
20
+ }
21
+
22
+ function resolvedRow(task: Task): string {
23
+ if (!task.checked) return "";
24
+ return [
25
+ `- **${task.id} — ${task.title}**`,
26
+ ` - verified by: \`${task.evidence.command}\``,
27
+ ` - observed: ${task.evidence.outcome}`,
28
+ ` - review candidate: ${task.candidate}`,
29
+ ].join("\n");
30
+ }
31
+
32
+ export function promotedRequirements(doc: FeatureDoc): string {
33
+ const done = doc.tasks.filter((task): task is Extract<Task, { checked: true }> => task.checked);
34
+ const remaining = doc.tasks.filter((task) => !task.checked);
35
+
36
+ const resolved =
37
+ done.length === 0
38
+ ? "None. No task in this run reached a verified completion, so nothing here is already done."
39
+ : [
40
+ "The work below is **already done and verified**. It must not be redone, re-planned or",
41
+ "re-implemented. Treat it as existing context; plan only what remains.",
42
+ "",
43
+ ...done.map(resolvedRow),
44
+ ].join("\n");
45
+
46
+ const remainingBody =
47
+ remaining.length === 0
48
+ ? "Everything recorded in the NODD run is complete; the remaining scope is whatever the objective still implies."
49
+ : remaining.map((task) => `- ${task.id} — ${task.title}`).join("\n");
50
+
51
+ return [
52
+ `# ${doc.title || doc.slug}`,
53
+ "",
54
+ `Promoted from the NODD run \`${doc.slug}\`. NODD kept the inline route until the work`,
55
+ "outgrew it; this document is the handoff, not a fresh start.",
56
+ "",
57
+ section("Objective", doc.objective, "_Not recorded in the NODD run._"),
58
+ section("Problem", doc.problem, "_Not recorded in the NODD run._"),
59
+ section("Scope", doc.scope, "_Not recorded in the NODD run._"),
60
+ section("Constraints", doc.constraints, "_Not recorded in the NODD run._"),
61
+ section("Remaining work", remainingBody, "_Nothing recorded._"),
62
+ section("Already resolved — do not redo", resolved, "None."),
63
+ ].join("\n");
64
+ }