@gonrocca/zero-pi 0.1.11 → 0.1.12
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 -21
- package/README.md +258 -291
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +520 -520
- package/extensions/conversation-resume.ts +399 -399
- package/extensions/provider-guard-extension.ts +195 -195
- package/extensions/provider-guard.ts +183 -183
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -237
- package/extensions/working-phrases.ts +295 -295
- package/extensions/zero-models.ts +333 -333
- package/package.json +73 -73
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +246 -246
- package/prompts/phases/build.md +20 -20
- package/prompts/phases/explore.md +22 -22
- package/prompts/phases/plan.md +82 -82
- package/prompts/phases/veredicto.md +30 -30
- package/skills/sdd-routing.md +52 -51
- package/skills/skill-loop.md +30 -29
- package/themes/zero-sdd.json +76 -76
package/extensions/spec-merge.ts
CHANGED
|
@@ -1,373 +1,373 @@
|
|
|
1
|
-
// zero-pi — canonical spec store delta-merge engine, pure-logic module.
|
|
2
|
-
//
|
|
3
|
-
// zero keeps a project-wide canonical spec store under `.sdd/specs/`. Every
|
|
4
|
-
// `/forge` run produces a *delta* (`.sdd/<slug>/spec.md`) and, once the run
|
|
5
|
-
// reaches a `pasa` verdict, the delta is folded into the store. The merge
|
|
6
|
-
// *must* be deterministic and reproducible (the same delta against the same
|
|
7
|
-
// store always yields the same result) — so every parsing, guardrail, and
|
|
8
|
-
// merge decision lives here in plain, dependency-free TypeScript, exactly like
|
|
9
|
-
// `autotune.ts`. The pi wiring lives in `spec-merge-extension.ts`.
|
|
10
|
-
//
|
|
11
|
-
// This file has no pi imports, no filesystem access, and no side-effecting
|
|
12
|
-
// top-level code: parsing and merging are pure string work. It never throws on
|
|
13
|
-
// malformed input — a bad emission degrades to a surfaced `MergeError`, never
|
|
14
|
-
// a corrupted store.
|
|
15
|
-
|
|
16
|
-
/** One named requirement: stable name + raw body text (criteria included). */
|
|
17
|
-
export interface RequirementBlock {
|
|
18
|
-
name: string;
|
|
19
|
-
/** Verbatim block body, trimmed; `""` allowed for a REMOVED entry. */
|
|
20
|
-
body: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** A parsed delta — three buckets, any may be empty. */
|
|
24
|
-
export interface SpecDelta {
|
|
25
|
-
added: RequirementBlock[];
|
|
26
|
-
modified: RequirementBlock[];
|
|
27
|
-
removed: RequirementBlock[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A guardrail violation. `kind` drives the human message. */
|
|
31
|
-
export interface MergeError {
|
|
32
|
-
kind:
|
|
33
|
-
| "duplicate-in-delta" // same name twice across/within delta sections
|
|
34
|
-
| "added-name-collision" // ADDED name already in the store
|
|
35
|
-
| "modified-missing" // MODIFIED names a block absent from the store
|
|
36
|
-
| "removed-missing" // REMOVED names a block absent from the store
|
|
37
|
-
| "parse-error"; // malformed store or delta input
|
|
38
|
-
/** Offending requirement name (`""` for a `parse-error`). */
|
|
39
|
-
name: string;
|
|
40
|
-
/** Ready-to-surface explanation. */
|
|
41
|
-
message: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** What a successful merge changed — drives the sync report. */
|
|
45
|
-
export interface MergeSummary {
|
|
46
|
-
added: string[]; // names appended
|
|
47
|
-
modified: string[]; // names replaced
|
|
48
|
-
removed: string[]; // names deleted
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Result of a merge attempt: either the new store text, or the errors. */
|
|
52
|
-
export type MergeResult =
|
|
53
|
-
| { ok: true; store: string; summary: MergeSummary }
|
|
54
|
-
| { ok: false; errors: MergeError[] };
|
|
55
|
-
|
|
56
|
-
/** The pinned `# ` title line of `.sdd/specs/requirements.md`. A stable title
|
|
57
|
-
* keeps `renderStore` ↔ `parseStore` a clean round-trip. */
|
|
58
|
-
export const STORE_TITLE = "Canonical specs";
|
|
59
|
-
|
|
60
|
-
/** Matches a requirement-block header: `### REQ: <name>`. The name is
|
|
61
|
-
* everything after `REQ:`, captured for trimming by the caller. */
|
|
62
|
-
const REQ_HEADER = /^###\s+REQ:\s*(.*)$/;
|
|
63
|
-
|
|
64
|
-
/** Matches a delta section header: `## ADDED` / `## MODIFIED` / `## REMOVED`
|
|
65
|
-
* (case-insensitive on the keyword). */
|
|
66
|
-
const SECTION_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED)\s*$/i;
|
|
67
|
-
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Parsing
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Split a run of text lines into `### REQ:` blocks.
|
|
74
|
-
*
|
|
75
|
-
* Each block starts at a `### REQ:` header and runs up to (but not including)
|
|
76
|
-
* the next `### REQ:` header or the end of the input. The name is everything
|
|
77
|
-
* after `REQ:`, trimmed; the body is every following line up to the next
|
|
78
|
-
* header, joined and trimmed. Lines before the first header are ignored — they
|
|
79
|
-
* are a title or a section heading the caller already consumed. A header with
|
|
80
|
-
* an empty name still produces a block (with `name === ""`); the caller's
|
|
81
|
-
* guardrails decide whether that is acceptable.
|
|
82
|
-
*/
|
|
83
|
-
function parseBlocks(lines: readonly string[]): RequirementBlock[] {
|
|
84
|
-
const blocks: RequirementBlock[] = [];
|
|
85
|
-
let current: { name: string; body: string[] } | null = null;
|
|
86
|
-
|
|
87
|
-
const flush = (): void => {
|
|
88
|
-
if (current !== null) {
|
|
89
|
-
blocks.push({ name: current.name, body: current.body.join("\n").trim() });
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
for (const line of lines) {
|
|
94
|
-
const header = line.match(REQ_HEADER);
|
|
95
|
-
if (header !== null) {
|
|
96
|
-
flush();
|
|
97
|
-
current = { name: header[1].trim(), body: [] };
|
|
98
|
-
} else if (current !== null) {
|
|
99
|
-
current.body.push(line);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
flush();
|
|
103
|
-
|
|
104
|
-
return blocks;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Parse a canonical store file into ordered blocks.
|
|
109
|
-
*
|
|
110
|
-
* The store is a `# ` title line followed by a sequence of `### REQ:` blocks —
|
|
111
|
-
* there are no `## ADDED` etc. headings in the *store*. Any line before the
|
|
112
|
-
* first `### REQ:` header (the title, blank lines) is ignored. Never throws:
|
|
113
|
-
* malformed input simply yields whatever blocks could be recognized — the
|
|
114
|
-
* caller's `parse-error` guardrail decides whether the result is usable.
|
|
115
|
-
* Empty or whitespace-only text yields `[]`.
|
|
116
|
-
*/
|
|
117
|
-
export function parseStore(text: string): RequirementBlock[] {
|
|
118
|
-
if (typeof text !== "string") return [];
|
|
119
|
-
return parseBlocks(text.split(/\r?\n/));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Parse a delta `spec.md` into a `SpecDelta`.
|
|
124
|
-
*
|
|
125
|
-
* Walks the file line by line, switching the active bucket on each
|
|
126
|
-
* `## ADDED` / `## MODIFIED` / `## REMOVED` header and collecting the
|
|
127
|
-
* `### REQ:` blocks that follow into that bucket. Lines outside any section
|
|
128
|
-
* (the `# ` title, blank lines) are ignored. An absent or empty section simply
|
|
129
|
-
* yields an empty bucket. Never throws — missing/non-string text yields an
|
|
130
|
-
* empty delta. A `### REQ:` block that appears before any section header is
|
|
131
|
-
* dropped (it belongs to no bucket); the caller treats a delta with zero
|
|
132
|
-
* blocks as "empty delta — nothing to sync", not an error.
|
|
133
|
-
*/
|
|
134
|
-
export function parseDelta(text: string): SpecDelta {
|
|
135
|
-
const delta: SpecDelta = { added: [], modified: [], removed: [] };
|
|
136
|
-
if (typeof text !== "string") return delta;
|
|
137
|
-
|
|
138
|
-
let section: keyof SpecDelta | null = null;
|
|
139
|
-
let buffer: string[] = [];
|
|
140
|
-
|
|
141
|
-
const flush = (): void => {
|
|
142
|
-
if (section !== null && buffer.length > 0) {
|
|
143
|
-
delta[section].push(...parseBlocks(buffer));
|
|
144
|
-
}
|
|
145
|
-
buffer = [];
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
for (const line of text.split(/\r?\n/)) {
|
|
149
|
-
const sectionHeader = line.match(SECTION_HEADER);
|
|
150
|
-
if (sectionHeader !== null) {
|
|
151
|
-
flush();
|
|
152
|
-
const keyword = sectionHeader[1].toUpperCase();
|
|
153
|
-
section =
|
|
154
|
-
keyword === "ADDED" ? "added" : keyword === "MODIFIED" ? "modified" : "removed";
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (section !== null) buffer.push(line);
|
|
158
|
-
}
|
|
159
|
-
flush();
|
|
160
|
-
|
|
161
|
-
return delta;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Total block count across the three delta buckets. */
|
|
165
|
-
function deltaBlockCount(delta: SpecDelta): number {
|
|
166
|
-
return delta.added.length + delta.modified.length + delta.removed.length;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// Guardrails
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Run every guardrail check against a parsed store + delta.
|
|
175
|
-
*
|
|
176
|
-
* Returns a `MergeError[]` — empty when the merge is safe to apply. Each error
|
|
177
|
-
* carries the offending `name` and a ready-to-surface `message`. The checks,
|
|
178
|
-
* in order:
|
|
179
|
-
*
|
|
180
|
-
* - `parse-error` — a block (store or delta) carries an empty name; the
|
|
181
|
-
* input could not be structurally understood.
|
|
182
|
-
* - `duplicate-in-delta` — the same name appears more than once across (or
|
|
183
|
-
* within) the ADDED/MODIFIED/REMOVED sections.
|
|
184
|
-
* - `added-name-collision` — an ADDED name already exists in the store (to
|
|
185
|
-
* *change* a block the run must use MODIFIED).
|
|
186
|
-
* - `modified-missing` — a MODIFIED block names a requirement absent from
|
|
187
|
-
* the store.
|
|
188
|
-
* - `removed-missing` — a REMOVED block names a requirement absent from the
|
|
189
|
-
* store.
|
|
190
|
-
*
|
|
191
|
-
* Pure, never throws. `mergeDelta` runs this before applying *any* change, so
|
|
192
|
-
* a non-empty result means the whole delta is rejected and the store is left
|
|
193
|
-
* untouched.
|
|
194
|
-
*/
|
|
195
|
-
export function checkGuardrails(
|
|
196
|
-
store: readonly RequirementBlock[],
|
|
197
|
-
delta: SpecDelta,
|
|
198
|
-
): MergeError[] {
|
|
199
|
-
const errors: MergeError[] = [];
|
|
200
|
-
|
|
201
|
-
// parse-error — any block (store or delta) with an empty name is malformed.
|
|
202
|
-
for (const block of store) {
|
|
203
|
-
if (block.name === "") {
|
|
204
|
-
errors.push({
|
|
205
|
-
kind: "parse-error",
|
|
206
|
-
name: "",
|
|
207
|
-
message:
|
|
208
|
-
"malformed store: a `### REQ:` block has an empty name — the store could not be parsed",
|
|
209
|
-
});
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
214
|
-
if (block.name === "") {
|
|
215
|
-
errors.push({
|
|
216
|
-
kind: "parse-error",
|
|
217
|
-
name: "",
|
|
218
|
-
message:
|
|
219
|
-
"malformed delta: a `### REQ:` block has an empty name — the delta could not be parsed",
|
|
220
|
-
});
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// duplicate-in-delta — the same name twice across all three delta sections.
|
|
226
|
-
const seen = new Set<string>();
|
|
227
|
-
const flagged = new Set<string>();
|
|
228
|
-
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
229
|
-
if (block.name === "") continue; // already reported as a parse-error
|
|
230
|
-
if (seen.has(block.name)) {
|
|
231
|
-
if (!flagged.has(block.name)) {
|
|
232
|
-
flagged.add(block.name);
|
|
233
|
-
errors.push({
|
|
234
|
-
kind: "duplicate-in-delta",
|
|
235
|
-
name: block.name,
|
|
236
|
-
message: `duplicate requirement "${block.name}": it appears more than once in the delta — each requirement may appear at most once across ADDED/MODIFIED/REMOVED`,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
} else {
|
|
240
|
-
seen.add(block.name);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const storeNames = new Set(store.map((b) => b.name));
|
|
245
|
-
|
|
246
|
-
// added-name-collision — an ADDED name already exists in the store.
|
|
247
|
-
for (const block of delta.added) {
|
|
248
|
-
if (block.name === "") continue;
|
|
249
|
-
if (storeNames.has(block.name)) {
|
|
250
|
-
errors.push({
|
|
251
|
-
kind: "added-name-collision",
|
|
252
|
-
name: block.name,
|
|
253
|
-
message: `ADDED requirement "${block.name}" already exists in the store — use MODIFIED to change an existing requirement`,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// modified-missing — a MODIFIED name is absent from the store.
|
|
259
|
-
for (const block of delta.modified) {
|
|
260
|
-
if (block.name === "") continue;
|
|
261
|
-
if (!storeNames.has(block.name)) {
|
|
262
|
-
errors.push({
|
|
263
|
-
kind: "modified-missing",
|
|
264
|
-
name: block.name,
|
|
265
|
-
message: `MODIFIED requirement "${block.name}" is not in the store — only an existing requirement can be modified`,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// removed-missing — a REMOVED name is absent from the store.
|
|
271
|
-
for (const block of delta.removed) {
|
|
272
|
-
if (block.name === "") continue;
|
|
273
|
-
if (!storeNames.has(block.name)) {
|
|
274
|
-
errors.push({
|
|
275
|
-
kind: "removed-missing",
|
|
276
|
-
name: block.name,
|
|
277
|
-
message: `REMOVED requirement "${block.name}" is not in the store — only an existing requirement can be removed`,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return errors;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ---------------------------------------------------------------------------
|
|
286
|
-
// Render
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Render an ordered block list back to canonical store markdown.
|
|
291
|
-
*
|
|
292
|
-
* The output is a `# ` title line, a blank line, then every block as a
|
|
293
|
-
* `### REQ: <name>` header followed by its body. Blocks are separated by a
|
|
294
|
-
* blank line. Round-trips with `parseStore`: `parseStore(renderStore(blocks))`
|
|
295
|
-
* yields the same names and (trimmed) bodies. `title` defaults to the pinned
|
|
296
|
-
* `STORE_TITLE`. The result always ends with a single trailing newline.
|
|
297
|
-
*/
|
|
298
|
-
export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
299
|
-
const parts: string[] = [`# ${title}`];
|
|
300
|
-
for (const block of blocks) {
|
|
301
|
-
const body = block.body.trim();
|
|
302
|
-
parts.push(body === "" ? `### REQ: ${block.name}` : `### REQ: ${block.name}\n\n${body}`);
|
|
303
|
-
}
|
|
304
|
-
return `${parts.join("\n\n")}\n`;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
// Merge entry point
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* The whole pipeline: parse both inputs, run every guardrail, and — only when
|
|
313
|
-
* there are zero errors — apply the delta and render the new store.
|
|
314
|
-
*
|
|
315
|
-
* This is the single entry point the `/zero-sync` wiring calls. It is pure: no
|
|
316
|
-
* filesystem, no pi, never throws.
|
|
317
|
-
*
|
|
318
|
-
* On any guardrail error it returns `{ ok: false, errors }` and **no store
|
|
319
|
-
* text is produced** — a partially-applied store is structurally impossible,
|
|
320
|
-
* because the merge runs after *all* guardrails pass. On success it returns
|
|
321
|
-
* `{ ok: true, store, summary }` where the delta has been applied as:
|
|
322
|
-
*
|
|
323
|
-
* - ADDED → appended to the end of the store, in delta order;
|
|
324
|
-
* - MODIFIED → the matching store block's body is replaced in place;
|
|
325
|
-
* - REMOVED → the matching store block is deleted.
|
|
326
|
-
*
|
|
327
|
-
* An empty delta (zero blocks in every section) is *not* an error: it returns
|
|
328
|
-
* `ok: true` with the store unchanged and an empty `summary`, so the caller
|
|
329
|
-
* can report "empty delta — nothing to sync".
|
|
330
|
-
*/
|
|
331
|
-
export function mergeDelta(storeText: string, deltaText: string): MergeResult {
|
|
332
|
-
const store = parseStore(storeText);
|
|
333
|
-
const delta = parseDelta(deltaText);
|
|
334
|
-
|
|
335
|
-
const errors = checkGuardrails(store, delta);
|
|
336
|
-
if (errors.length > 0) {
|
|
337
|
-
return { ok: false, errors };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Apply the delta. Guardrails have proven every name resolves, so the
|
|
341
|
-
// mutations below cannot fail or leave the store half-applied.
|
|
342
|
-
const removedNames = new Set(delta.removed.map((b) => b.name));
|
|
343
|
-
const modifiedByName = new Map(delta.modified.map((b) => [b.name, b]));
|
|
344
|
-
|
|
345
|
-
const merged: RequirementBlock[] = [];
|
|
346
|
-
for (const block of store) {
|
|
347
|
-
if (removedNames.has(block.name)) continue; // REMOVED — drop it
|
|
348
|
-
const replacement = modifiedByName.get(block.name);
|
|
349
|
-
merged.push(replacement ?? block); // MODIFIED — replace; else keep
|
|
350
|
-
}
|
|
351
|
-
for (const block of delta.added) {
|
|
352
|
-
merged.push(block); // ADDED — append in delta order
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const summary: MergeSummary = {
|
|
356
|
-
added: delta.added.map((b) => b.name),
|
|
357
|
-
modified: delta.modified.map((b) => b.name),
|
|
358
|
-
removed: delta.removed.map((b) => b.name),
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
return { ok: true, store: renderStore(merged), summary };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Whether a parsed delta carries no blocks at all.
|
|
366
|
-
*
|
|
367
|
-
* The `/zero-sync` command uses this to report "empty delta — nothing to sync"
|
|
368
|
-
* before touching the store: a zero-block delta is valid (not a `MergeError`),
|
|
369
|
-
* it simply has nothing to apply.
|
|
370
|
-
*/
|
|
371
|
-
export function isEmptyDelta(delta: SpecDelta): boolean {
|
|
372
|
-
return deltaBlockCount(delta) === 0;
|
|
373
|
-
}
|
|
1
|
+
// zero-pi — canonical spec store delta-merge engine, pure-logic module.
|
|
2
|
+
//
|
|
3
|
+
// zero keeps a project-wide canonical spec store under `.sdd/specs/`. Every
|
|
4
|
+
// `/forge` run produces a *delta* (`.sdd/<slug>/spec.md`) and, once the run
|
|
5
|
+
// reaches a `pasa` verdict, the delta is folded into the store. The merge
|
|
6
|
+
// *must* be deterministic and reproducible (the same delta against the same
|
|
7
|
+
// store always yields the same result) — so every parsing, guardrail, and
|
|
8
|
+
// merge decision lives here in plain, dependency-free TypeScript, exactly like
|
|
9
|
+
// `autotune.ts`. The pi wiring lives in `spec-merge-extension.ts`.
|
|
10
|
+
//
|
|
11
|
+
// This file has no pi imports, no filesystem access, and no side-effecting
|
|
12
|
+
// top-level code: parsing and merging are pure string work. It never throws on
|
|
13
|
+
// malformed input — a bad emission degrades to a surfaced `MergeError`, never
|
|
14
|
+
// a corrupted store.
|
|
15
|
+
|
|
16
|
+
/** One named requirement: stable name + raw body text (criteria included). */
|
|
17
|
+
export interface RequirementBlock {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Verbatim block body, trimmed; `""` allowed for a REMOVED entry. */
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A parsed delta — three buckets, any may be empty. */
|
|
24
|
+
export interface SpecDelta {
|
|
25
|
+
added: RequirementBlock[];
|
|
26
|
+
modified: RequirementBlock[];
|
|
27
|
+
removed: RequirementBlock[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A guardrail violation. `kind` drives the human message. */
|
|
31
|
+
export interface MergeError {
|
|
32
|
+
kind:
|
|
33
|
+
| "duplicate-in-delta" // same name twice across/within delta sections
|
|
34
|
+
| "added-name-collision" // ADDED name already in the store
|
|
35
|
+
| "modified-missing" // MODIFIED names a block absent from the store
|
|
36
|
+
| "removed-missing" // REMOVED names a block absent from the store
|
|
37
|
+
| "parse-error"; // malformed store or delta input
|
|
38
|
+
/** Offending requirement name (`""` for a `parse-error`). */
|
|
39
|
+
name: string;
|
|
40
|
+
/** Ready-to-surface explanation. */
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a successful merge changed — drives the sync report. */
|
|
45
|
+
export interface MergeSummary {
|
|
46
|
+
added: string[]; // names appended
|
|
47
|
+
modified: string[]; // names replaced
|
|
48
|
+
removed: string[]; // names deleted
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of a merge attempt: either the new store text, or the errors. */
|
|
52
|
+
export type MergeResult =
|
|
53
|
+
| { ok: true; store: string; summary: MergeSummary }
|
|
54
|
+
| { ok: false; errors: MergeError[] };
|
|
55
|
+
|
|
56
|
+
/** The pinned `# ` title line of `.sdd/specs/requirements.md`. A stable title
|
|
57
|
+
* keeps `renderStore` ↔ `parseStore` a clean round-trip. */
|
|
58
|
+
export const STORE_TITLE = "Canonical specs";
|
|
59
|
+
|
|
60
|
+
/** Matches a requirement-block header: `### REQ: <name>`. The name is
|
|
61
|
+
* everything after `REQ:`, captured for trimming by the caller. */
|
|
62
|
+
const REQ_HEADER = /^###\s+REQ:\s*(.*)$/;
|
|
63
|
+
|
|
64
|
+
/** Matches a delta section header: `## ADDED` / `## MODIFIED` / `## REMOVED`
|
|
65
|
+
* (case-insensitive on the keyword). */
|
|
66
|
+
const SECTION_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED)\s*$/i;
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Parsing
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Split a run of text lines into `### REQ:` blocks.
|
|
74
|
+
*
|
|
75
|
+
* Each block starts at a `### REQ:` header and runs up to (but not including)
|
|
76
|
+
* the next `### REQ:` header or the end of the input. The name is everything
|
|
77
|
+
* after `REQ:`, trimmed; the body is every following line up to the next
|
|
78
|
+
* header, joined and trimmed. Lines before the first header are ignored — they
|
|
79
|
+
* are a title or a section heading the caller already consumed. A header with
|
|
80
|
+
* an empty name still produces a block (with `name === ""`); the caller's
|
|
81
|
+
* guardrails decide whether that is acceptable.
|
|
82
|
+
*/
|
|
83
|
+
function parseBlocks(lines: readonly string[]): RequirementBlock[] {
|
|
84
|
+
const blocks: RequirementBlock[] = [];
|
|
85
|
+
let current: { name: string; body: string[] } | null = null;
|
|
86
|
+
|
|
87
|
+
const flush = (): void => {
|
|
88
|
+
if (current !== null) {
|
|
89
|
+
blocks.push({ name: current.name, body: current.body.join("\n").trim() });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const header = line.match(REQ_HEADER);
|
|
95
|
+
if (header !== null) {
|
|
96
|
+
flush();
|
|
97
|
+
current = { name: header[1].trim(), body: [] };
|
|
98
|
+
} else if (current !== null) {
|
|
99
|
+
current.body.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
flush();
|
|
103
|
+
|
|
104
|
+
return blocks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a canonical store file into ordered blocks.
|
|
109
|
+
*
|
|
110
|
+
* The store is a `# ` title line followed by a sequence of `### REQ:` blocks —
|
|
111
|
+
* there are no `## ADDED` etc. headings in the *store*. Any line before the
|
|
112
|
+
* first `### REQ:` header (the title, blank lines) is ignored. Never throws:
|
|
113
|
+
* malformed input simply yields whatever blocks could be recognized — the
|
|
114
|
+
* caller's `parse-error` guardrail decides whether the result is usable.
|
|
115
|
+
* Empty or whitespace-only text yields `[]`.
|
|
116
|
+
*/
|
|
117
|
+
export function parseStore(text: string): RequirementBlock[] {
|
|
118
|
+
if (typeof text !== "string") return [];
|
|
119
|
+
return parseBlocks(text.split(/\r?\n/));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a delta `spec.md` into a `SpecDelta`.
|
|
124
|
+
*
|
|
125
|
+
* Walks the file line by line, switching the active bucket on each
|
|
126
|
+
* `## ADDED` / `## MODIFIED` / `## REMOVED` header and collecting the
|
|
127
|
+
* `### REQ:` blocks that follow into that bucket. Lines outside any section
|
|
128
|
+
* (the `# ` title, blank lines) are ignored. An absent or empty section simply
|
|
129
|
+
* yields an empty bucket. Never throws — missing/non-string text yields an
|
|
130
|
+
* empty delta. A `### REQ:` block that appears before any section header is
|
|
131
|
+
* dropped (it belongs to no bucket); the caller treats a delta with zero
|
|
132
|
+
* blocks as "empty delta — nothing to sync", not an error.
|
|
133
|
+
*/
|
|
134
|
+
export function parseDelta(text: string): SpecDelta {
|
|
135
|
+
const delta: SpecDelta = { added: [], modified: [], removed: [] };
|
|
136
|
+
if (typeof text !== "string") return delta;
|
|
137
|
+
|
|
138
|
+
let section: keyof SpecDelta | null = null;
|
|
139
|
+
let buffer: string[] = [];
|
|
140
|
+
|
|
141
|
+
const flush = (): void => {
|
|
142
|
+
if (section !== null && buffer.length > 0) {
|
|
143
|
+
delta[section].push(...parseBlocks(buffer));
|
|
144
|
+
}
|
|
145
|
+
buffer = [];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const line of text.split(/\r?\n/)) {
|
|
149
|
+
const sectionHeader = line.match(SECTION_HEADER);
|
|
150
|
+
if (sectionHeader !== null) {
|
|
151
|
+
flush();
|
|
152
|
+
const keyword = sectionHeader[1].toUpperCase();
|
|
153
|
+
section =
|
|
154
|
+
keyword === "ADDED" ? "added" : keyword === "MODIFIED" ? "modified" : "removed";
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (section !== null) buffer.push(line);
|
|
158
|
+
}
|
|
159
|
+
flush();
|
|
160
|
+
|
|
161
|
+
return delta;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Total block count across the three delta buckets. */
|
|
165
|
+
function deltaBlockCount(delta: SpecDelta): number {
|
|
166
|
+
return delta.added.length + delta.modified.length + delta.removed.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Guardrails
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run every guardrail check against a parsed store + delta.
|
|
175
|
+
*
|
|
176
|
+
* Returns a `MergeError[]` — empty when the merge is safe to apply. Each error
|
|
177
|
+
* carries the offending `name` and a ready-to-surface `message`. The checks,
|
|
178
|
+
* in order:
|
|
179
|
+
*
|
|
180
|
+
* - `parse-error` — a block (store or delta) carries an empty name; the
|
|
181
|
+
* input could not be structurally understood.
|
|
182
|
+
* - `duplicate-in-delta` — the same name appears more than once across (or
|
|
183
|
+
* within) the ADDED/MODIFIED/REMOVED sections.
|
|
184
|
+
* - `added-name-collision` — an ADDED name already exists in the store (to
|
|
185
|
+
* *change* a block the run must use MODIFIED).
|
|
186
|
+
* - `modified-missing` — a MODIFIED block names a requirement absent from
|
|
187
|
+
* the store.
|
|
188
|
+
* - `removed-missing` — a REMOVED block names a requirement absent from the
|
|
189
|
+
* store.
|
|
190
|
+
*
|
|
191
|
+
* Pure, never throws. `mergeDelta` runs this before applying *any* change, so
|
|
192
|
+
* a non-empty result means the whole delta is rejected and the store is left
|
|
193
|
+
* untouched.
|
|
194
|
+
*/
|
|
195
|
+
export function checkGuardrails(
|
|
196
|
+
store: readonly RequirementBlock[],
|
|
197
|
+
delta: SpecDelta,
|
|
198
|
+
): MergeError[] {
|
|
199
|
+
const errors: MergeError[] = [];
|
|
200
|
+
|
|
201
|
+
// parse-error — any block (store or delta) with an empty name is malformed.
|
|
202
|
+
for (const block of store) {
|
|
203
|
+
if (block.name === "") {
|
|
204
|
+
errors.push({
|
|
205
|
+
kind: "parse-error",
|
|
206
|
+
name: "",
|
|
207
|
+
message:
|
|
208
|
+
"malformed store: a `### REQ:` block has an empty name — the store could not be parsed",
|
|
209
|
+
});
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
214
|
+
if (block.name === "") {
|
|
215
|
+
errors.push({
|
|
216
|
+
kind: "parse-error",
|
|
217
|
+
name: "",
|
|
218
|
+
message:
|
|
219
|
+
"malformed delta: a `### REQ:` block has an empty name — the delta could not be parsed",
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// duplicate-in-delta — the same name twice across all three delta sections.
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const flagged = new Set<string>();
|
|
228
|
+
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
229
|
+
if (block.name === "") continue; // already reported as a parse-error
|
|
230
|
+
if (seen.has(block.name)) {
|
|
231
|
+
if (!flagged.has(block.name)) {
|
|
232
|
+
flagged.add(block.name);
|
|
233
|
+
errors.push({
|
|
234
|
+
kind: "duplicate-in-delta",
|
|
235
|
+
name: block.name,
|
|
236
|
+
message: `duplicate requirement "${block.name}": it appears more than once in the delta — each requirement may appear at most once across ADDED/MODIFIED/REMOVED`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
seen.add(block.name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const storeNames = new Set(store.map((b) => b.name));
|
|
245
|
+
|
|
246
|
+
// added-name-collision — an ADDED name already exists in the store.
|
|
247
|
+
for (const block of delta.added) {
|
|
248
|
+
if (block.name === "") continue;
|
|
249
|
+
if (storeNames.has(block.name)) {
|
|
250
|
+
errors.push({
|
|
251
|
+
kind: "added-name-collision",
|
|
252
|
+
name: block.name,
|
|
253
|
+
message: `ADDED requirement "${block.name}" already exists in the store — use MODIFIED to change an existing requirement`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// modified-missing — a MODIFIED name is absent from the store.
|
|
259
|
+
for (const block of delta.modified) {
|
|
260
|
+
if (block.name === "") continue;
|
|
261
|
+
if (!storeNames.has(block.name)) {
|
|
262
|
+
errors.push({
|
|
263
|
+
kind: "modified-missing",
|
|
264
|
+
name: block.name,
|
|
265
|
+
message: `MODIFIED requirement "${block.name}" is not in the store — only an existing requirement can be modified`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// removed-missing — a REMOVED name is absent from the store.
|
|
271
|
+
for (const block of delta.removed) {
|
|
272
|
+
if (block.name === "") continue;
|
|
273
|
+
if (!storeNames.has(block.name)) {
|
|
274
|
+
errors.push({
|
|
275
|
+
kind: "removed-missing",
|
|
276
|
+
name: block.name,
|
|
277
|
+
message: `REMOVED requirement "${block.name}" is not in the store — only an existing requirement can be removed`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return errors;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Render
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Render an ordered block list back to canonical store markdown.
|
|
291
|
+
*
|
|
292
|
+
* The output is a `# ` title line, a blank line, then every block as a
|
|
293
|
+
* `### REQ: <name>` header followed by its body. Blocks are separated by a
|
|
294
|
+
* blank line. Round-trips with `parseStore`: `parseStore(renderStore(blocks))`
|
|
295
|
+
* yields the same names and (trimmed) bodies. `title` defaults to the pinned
|
|
296
|
+
* `STORE_TITLE`. The result always ends with a single trailing newline.
|
|
297
|
+
*/
|
|
298
|
+
export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
299
|
+
const parts: string[] = [`# ${title}`];
|
|
300
|
+
for (const block of blocks) {
|
|
301
|
+
const body = block.body.trim();
|
|
302
|
+
parts.push(body === "" ? `### REQ: ${block.name}` : `### REQ: ${block.name}\n\n${body}`);
|
|
303
|
+
}
|
|
304
|
+
return `${parts.join("\n\n")}\n`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Merge entry point
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The whole pipeline: parse both inputs, run every guardrail, and — only when
|
|
313
|
+
* there are zero errors — apply the delta and render the new store.
|
|
314
|
+
*
|
|
315
|
+
* This is the single entry point the `/zero-sync` wiring calls. It is pure: no
|
|
316
|
+
* filesystem, no pi, never throws.
|
|
317
|
+
*
|
|
318
|
+
* On any guardrail error it returns `{ ok: false, errors }` and **no store
|
|
319
|
+
* text is produced** — a partially-applied store is structurally impossible,
|
|
320
|
+
* because the merge runs after *all* guardrails pass. On success it returns
|
|
321
|
+
* `{ ok: true, store, summary }` where the delta has been applied as:
|
|
322
|
+
*
|
|
323
|
+
* - ADDED → appended to the end of the store, in delta order;
|
|
324
|
+
* - MODIFIED → the matching store block's body is replaced in place;
|
|
325
|
+
* - REMOVED → the matching store block is deleted.
|
|
326
|
+
*
|
|
327
|
+
* An empty delta (zero blocks in every section) is *not* an error: it returns
|
|
328
|
+
* `ok: true` with the store unchanged and an empty `summary`, so the caller
|
|
329
|
+
* can report "empty delta — nothing to sync".
|
|
330
|
+
*/
|
|
331
|
+
export function mergeDelta(storeText: string, deltaText: string): MergeResult {
|
|
332
|
+
const store = parseStore(storeText);
|
|
333
|
+
const delta = parseDelta(deltaText);
|
|
334
|
+
|
|
335
|
+
const errors = checkGuardrails(store, delta);
|
|
336
|
+
if (errors.length > 0) {
|
|
337
|
+
return { ok: false, errors };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Apply the delta. Guardrails have proven every name resolves, so the
|
|
341
|
+
// mutations below cannot fail or leave the store half-applied.
|
|
342
|
+
const removedNames = new Set(delta.removed.map((b) => b.name));
|
|
343
|
+
const modifiedByName = new Map(delta.modified.map((b) => [b.name, b]));
|
|
344
|
+
|
|
345
|
+
const merged: RequirementBlock[] = [];
|
|
346
|
+
for (const block of store) {
|
|
347
|
+
if (removedNames.has(block.name)) continue; // REMOVED — drop it
|
|
348
|
+
const replacement = modifiedByName.get(block.name);
|
|
349
|
+
merged.push(replacement ?? block); // MODIFIED — replace; else keep
|
|
350
|
+
}
|
|
351
|
+
for (const block of delta.added) {
|
|
352
|
+
merged.push(block); // ADDED — append in delta order
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const summary: MergeSummary = {
|
|
356
|
+
added: delta.added.map((b) => b.name),
|
|
357
|
+
modified: delta.modified.map((b) => b.name),
|
|
358
|
+
removed: delta.removed.map((b) => b.name),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { ok: true, store: renderStore(merged), summary };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether a parsed delta carries no blocks at all.
|
|
366
|
+
*
|
|
367
|
+
* The `/zero-sync` command uses this to report "empty delta — nothing to sync"
|
|
368
|
+
* before touching the store: a zero-block delta is valid (not a `MergeError`),
|
|
369
|
+
* it simply has nothing to apply.
|
|
370
|
+
*/
|
|
371
|
+
export function isEmptyDelta(delta: SpecDelta): boolean {
|
|
372
|
+
return deltaBlockCount(delta) === 0;
|
|
373
|
+
}
|