@gonrocca/zero-pi 0.1.48 → 0.1.50

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.
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface SddConfig {
5
+ git: {
6
+ branchPrefix: string;
7
+ numbering: boolean;
8
+ autoCommit: boolean;
9
+ commitStyle: "conventional" | "plain";
10
+ baseBranch: string;
11
+ };
12
+ }
13
+
14
+ export const DEFAULT_SDD_CONFIG: SddConfig = {
15
+ git: { branchPrefix: "sdd/", numbering: false, autoCommit: false, commitStyle: "conventional", baseBranch: "main" },
16
+ };
17
+
18
+ function asObject(value: unknown): Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
20
+ }
21
+
22
+ export function loadSddConfig(root = process.cwd()): SddConfig {
23
+ const path = join(root, ".sdd", "config.json");
24
+ if (!existsSync(path)) return structuredClone(DEFAULT_SDD_CONFIG);
25
+ let parsed: Record<string, unknown>;
26
+ try { parsed = asObject(JSON.parse(readFileSync(path, "utf8"))); }
27
+ catch (err) { throw new Error(`invalid .sdd/config.json: ${err instanceof Error ? err.message : String(err)}`); }
28
+ const git = asObject(parsed.git);
29
+ return {
30
+ git: {
31
+ branchPrefix: typeof git.branchPrefix === "string" ? git.branchPrefix : DEFAULT_SDD_CONFIG.git.branchPrefix,
32
+ numbering: typeof git.numbering === "boolean" ? git.numbering : DEFAULT_SDD_CONFIG.git.numbering,
33
+ autoCommit: typeof git.autoCommit === "boolean" ? git.autoCommit : DEFAULT_SDD_CONFIG.git.autoCommit,
34
+ commitStyle: git.commitStyle === "plain" ? "plain" : DEFAULT_SDD_CONFIG.git.commitStyle,
35
+ baseBranch: typeof git.baseBranch === "string" ? git.baseBranch : DEFAULT_SDD_CONFIG.git.baseBranch,
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export interface SddLinks {
5
+ prNumber?: number;
6
+ prUrl?: string;
7
+ issueNumber?: number;
8
+ issueUrl?: string;
9
+ branch?: string;
10
+ baseBranch?: string;
11
+ archivePath?: string;
12
+ createdAt?: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export type LinksRecord = SddLinks;
17
+
18
+ function linksPath(sddDir: string, slug: string): string {
19
+ return join(sddDir, slug, "links.json");
20
+ }
21
+
22
+ function validRecord(value: unknown): SddLinks {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as SddLinks : {};
24
+ }
25
+
26
+ export function readLinks(sddDir: string, slug: string): SddLinks {
27
+ const path = linksPath(sddDir, slug);
28
+ if (!existsSync(path)) return {};
29
+ try {
30
+ return validRecord(JSON.parse(readFileSync(path, "utf8")));
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ export function mergeLinks(current: SddLinks, partial: SddLinks): SddLinks {
37
+ return { ...validRecord(current), ...validRecord(partial) };
38
+ }
39
+
40
+ export function writeLinks(sddDir: string, slug: string, partial: SddLinks): SddLinks {
41
+ const path = linksPath(sddDir, slug);
42
+ const next = mergeLinks(readLinks(sddDir, slug), partial);
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
45
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
46
+ renameSync(tmp, path);
47
+ return next;
48
+ }
@@ -114,6 +114,7 @@ function renderSyncReport(slug: string, date: string, summary: MergeSummary): st
114
114
  "",
115
115
  section("Added", summary.added),
116
116
  section("Modified", summary.modified),
117
+ section("Renamed", summary.renamed.map((r) => `${r.from} -> ${r.to}`)),
117
118
  section("Removed", summary.removed),
118
119
  ].join("\n");
119
120
  }
@@ -125,6 +126,9 @@ function describeSummary(summary: MergeSummary): string {
125
126
  if (summary.modified.length > 0) {
126
127
  lines.push(` modificados: ${summary.modified.join(", ")} (reemplaza el texto de spec existente)`);
127
128
  }
129
+ if (summary.renamed.length > 0) {
130
+ lines.push(` renombrados: ${summary.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")} (mantiene la posición en el store)`);
131
+ }
128
132
  if (summary.removed.length > 0) {
129
133
  lines.push(` eliminados: ${summary.removed.join(", ")} (borra del store)`);
130
134
  }
@@ -1,95 +1,65 @@
1
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
2
 
16
3
  /** One named requirement: stable name + raw body text (criteria included). */
17
4
  export interface RequirementBlock {
18
5
  name: string;
19
- /** Verbatim block body, trimmed; `""` allowed for a REMOVED entry. */
20
6
  body: string;
21
7
  }
22
8
 
23
- /** A parsed delta — three buckets, any may be empty. */
9
+ export interface RenameBlock {
10
+ from: string;
11
+ to: string;
12
+ body: string;
13
+ }
14
+
15
+ /** A parsed delta — four buckets, any may be empty. */
24
16
  export interface SpecDelta {
25
17
  added: RequirementBlock[];
26
18
  modified: RequirementBlock[];
27
19
  removed: RequirementBlock[];
20
+ renamed: RenameBlock[];
28
21
  }
29
22
 
30
23
  /** A guardrail violation. `kind` drives the human message. */
31
24
  export interface MergeError {
32
25
  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`). */
26
+ | "duplicate-in-delta"
27
+ | "added-name-collision"
28
+ | "modified-missing"
29
+ | "removed-missing"
30
+ | "renamed-missing"
31
+ | "renamed-to-collision"
32
+ | "renamed-duplicate"
33
+ | "renamed-parse-error"
34
+ | "parse-error";
39
35
  name: string;
40
- /** Ready-to-surface explanation. */
41
36
  message: string;
42
37
  }
43
38
 
44
39
  /** What a successful merge changed — drives the sync report. */
45
40
  export interface MergeSummary {
46
- added: string[]; // names appended
47
- modified: string[]; // names replaced
48
- removed: string[]; // names deleted
41
+ added: string[];
42
+ modified: string[];
43
+ removed: string[];
44
+ renamed: { from: string; to: string }[];
49
45
  }
50
46
 
51
- /** Result of a merge attempt: either the new store text, or the errors. */
52
47
  export type MergeResult =
53
48
  | { ok: true; store: string; summary: MergeSummary }
54
49
  | { ok: false; errors: MergeError[] };
55
50
 
56
- /** The pinned `# ` title line of `.sdd/specs/requirements.md`. A stable title
57
- * keeps `renderStore` ↔ `parseStore` a clean round-trip. */
58
51
  export const STORE_TITLE = "Canonical specs";
59
52
 
60
- /** Matches a requirement-block header: `### REQ: <name>`. The name is
61
- * everything after `REQ:`, captured for trimming by the caller. */
62
53
  const REQ_HEADER = /^###\s+REQ:\s*(.*)$/;
54
+ const SECTION_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s*$/i;
55
+ const FROM_LINE = /^from:\s*(.+?)\s*$/i;
63
56
 
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
57
  function parseBlocks(lines: readonly string[]): RequirementBlock[] {
84
58
  const blocks: RequirementBlock[] = [];
85
59
  let current: { name: string; body: string[] } | null = null;
86
-
87
60
  const flush = (): void => {
88
- if (current !== null) {
89
- blocks.push({ name: current.name, body: current.body.join("\n").trim() });
90
- }
61
+ if (current !== null) blocks.push({ name: current.name, body: current.body.join("\n").trim() });
91
62
  };
92
-
93
63
  for (const line of lines) {
94
64
  const header = line.match(REQ_HEADER);
95
65
  if (header !== null) {
@@ -100,47 +70,52 @@ function parseBlocks(lines: readonly string[]): RequirementBlock[] {
100
70
  }
101
71
  }
102
72
  flush();
103
-
104
73
  return blocks;
105
74
  }
106
75
 
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
- */
76
+ function parseRenameBlocks(lines: readonly string[]): RenameBlock[] {
77
+ return parseBlocks(lines).map((block) => {
78
+ const bodyLines = block.body.split(/\r?\n/);
79
+ let from = "";
80
+ const kept: string[] = [];
81
+ let lifted = false;
82
+ for (const line of bodyLines) {
83
+ const match = line.match(FROM_LINE);
84
+ if (!lifted && match !== null) {
85
+ from = match[1].trim();
86
+ lifted = true;
87
+ } else {
88
+ kept.push(line);
89
+ }
90
+ }
91
+ return { from, to: block.name, body: kept.join("\n").trim() };
92
+ });
93
+ }
94
+
117
95
  export function parseStore(text: string): RequirementBlock[] {
118
96
  if (typeof text !== "string") return [];
119
97
  return parseBlocks(text.split(/\r?\n/));
120
98
  }
121
99
 
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
- */
100
+ export function loadCanonical(text: string | undefined | null): { blocks: RequirementBlock[]; errors: MergeError[] } {
101
+ const source = text ?? "";
102
+ const blocks = parseStore(source);
103
+ const errors = blocks.some((b) => b.name === "")
104
+ ? [{ kind: "parse-error" as const, name: "", message: "malformed canonical store: empty REQ name" }]
105
+ : [];
106
+ return { blocks, errors };
107
+ }
108
+
134
109
  export function parseDelta(text: string): SpecDelta {
135
- const delta: SpecDelta = { added: [], modified: [], removed: [] };
110
+ const delta: SpecDelta = { added: [], modified: [], removed: [], renamed: [] };
136
111
  if (typeof text !== "string") return delta;
137
112
 
138
113
  let section: keyof SpecDelta | null = null;
139
114
  let buffer: string[] = [];
140
-
141
115
  const flush = (): void => {
142
116
  if (section !== null && buffer.length > 0) {
143
- delta[section].push(...parseBlocks(buffer));
117
+ if (section === "renamed") delta.renamed.push(...parseRenameBlocks(buffer));
118
+ else delta[section].push(...parseBlocks(buffer));
144
119
  }
145
120
  buffer = [];
146
121
  };
@@ -151,150 +126,113 @@ export function parseDelta(text: string): SpecDelta {
151
126
  flush();
152
127
  const keyword = sectionHeader[1].toUpperCase();
153
128
  section =
154
- keyword === "ADDED" ? "added" : keyword === "MODIFIED" ? "modified" : "removed";
129
+ keyword === "ADDED"
130
+ ? "added"
131
+ : keyword === "MODIFIED"
132
+ ? "modified"
133
+ : keyword === "REMOVED"
134
+ ? "removed"
135
+ : "renamed";
155
136
  continue;
156
137
  }
157
138
  if (section !== null) buffer.push(line);
158
139
  }
159
140
  flush();
160
-
161
141
  return delta;
162
142
  }
163
143
 
164
- /** Total block count across the three delta buckets. */
165
144
  function deltaBlockCount(delta: SpecDelta): number {
166
- return delta.added.length + delta.modified.length + delta.removed.length;
145
+ return delta.added.length + delta.modified.length + delta.removed.length + delta.renamed.length;
167
146
  }
168
147
 
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
148
  export function checkGuardrails(
196
149
  store: readonly RequirementBlock[],
197
150
  delta: SpecDelta,
198
151
  ): MergeError[] {
199
152
  const errors: MergeError[] = [];
200
153
 
201
- // parse-error — any block (store or delta) with an empty name is malformed.
202
154
  for (const block of store) {
203
155
  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
- });
156
+ errors.push({ kind: "parse-error", name: "", message: "malformed store: a `### REQ:` block has an empty name — the store could not be parsed" });
210
157
  break;
211
158
  }
212
159
  }
213
160
  for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
214
161
  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
- });
162
+ errors.push({ kind: "parse-error", name: "", message: "malformed delta: a `### REQ:` block has an empty name — the delta could not be parsed" });
163
+ break;
164
+ }
165
+ }
166
+ for (const block of delta.renamed) {
167
+ if (block.to === "" || block.from === "") {
168
+ errors.push({ kind: "renamed-parse-error", name: block.to || block.from, message: `RENAMED requirement "${block.to || block.from || "(empty)"}" must include both a target name and a \`from: <name>\` line` });
221
169
  break;
222
170
  }
223
171
  }
224
172
 
225
- // duplicate-in-delta — the same name twice across all three delta sections.
226
173
  const seen = new Set<string>();
227
174
  const flagged = new Set<string>();
228
175
  for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
229
- if (block.name === "") continue; // already reported as a parse-error
176
+ if (block.name === "") continue;
230
177
  if (seen.has(block.name)) {
231
178
  if (!flagged.has(block.name)) {
232
179
  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
- });
180
+ errors.push({ kind: "duplicate-in-delta", name: block.name, message: `duplicate requirement "${block.name}": it appears more than once in the delta — each requirement may appear at most once across ADDED/MODIFIED/REMOVED` });
238
181
  }
239
- } else {
240
- seen.add(block.name);
182
+ } else seen.add(block.name);
183
+ }
184
+
185
+ const renameSeen = new Set<string>();
186
+ const modifiedNames = new Set(delta.modified.map((b) => b.name));
187
+ for (const block of delta.renamed) {
188
+ if (block.from === "" || block.to === "") continue;
189
+ for (const name of [block.from, block.to]) {
190
+ const allowedRenameThenModify = name === block.to && modifiedNames.has(name);
191
+ if (renameSeen.has(name) || (seen.has(name) && !allowedRenameThenModify)) {
192
+ if (!flagged.has(name)) {
193
+ flagged.add(name);
194
+ errors.push({ kind: "renamed-duplicate", name, message: `RENAMED requirement "${name}" conflicts with another delta entry — rename source and target names must be unique` });
195
+ }
196
+ } else renameSeen.add(name);
241
197
  }
242
198
  }
243
199
 
244
200
  const storeNames = new Set(store.map((b) => b.name));
201
+ const removedNames = new Set(delta.removed.map((b) => b.name));
202
+ const renameFrom = new Set(delta.renamed.map((b) => b.from).filter(Boolean));
245
203
 
246
- // added-name-collision — an ADDED name already exists in the store.
247
204
  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
- });
205
+ if (block.name !== "" && storeNames.has(block.name) && !removedNames.has(block.name) && !renameFrom.has(block.name)) {
206
+ errors.push({ kind: "added-name-collision", name: block.name, message: `ADDED requirement "${block.name}" already exists in the store — use MODIFIED to change an existing requirement` });
255
207
  }
256
208
  }
257
-
258
- // modified-missing — a MODIFIED name is absent from the store.
259
209
  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
- });
210
+ if (block.name !== "" && !storeNames.has(block.name) && !delta.renamed.some((r) => r.to === block.name)) {
211
+ errors.push({ kind: "modified-missing", name: block.name, message: `MODIFIED requirement "${block.name}" is not in the store — only an existing requirement can be modified` });
267
212
  }
268
213
  }
269
-
270
- // removed-missing — a REMOVED name is absent from the store.
271
214
  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
- });
215
+ if (block.name !== "" && !storeNames.has(block.name)) {
216
+ errors.push({ kind: "removed-missing", name: block.name, message: `REMOVED requirement "${block.name}" is not in the store — only an existing requirement can be removed` });
217
+ }
218
+ }
219
+ for (const block of delta.renamed) {
220
+ if (block.from === "" || block.to === "") continue;
221
+ if (!storeNames.has(block.from)) {
222
+ errors.push({ kind: "renamed-missing", name: block.from, message: `RENAMED requirement "${block.from}" is not in the store — only an existing requirement can be renamed` });
223
+ }
224
+ if (storeNames.has(block.to) && block.to !== block.from && !removedNames.has(block.to) && !renameFrom.has(block.to)) {
225
+ errors.push({ kind: "renamed-to-collision", name: block.to, message: `RENAMED target "${block.to}" already exists in the store — choose a new requirement name` });
279
226
  }
280
227
  }
281
228
 
282
229
  return errors;
283
230
  }
284
231
 
285
- // ---------------------------------------------------------------------------
286
- // Render
287
- // ---------------------------------------------------------------------------
232
+ export function serializeCanonical(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
233
+ return renderStore([...blocks].sort((a, b) => a.name.localeCompare(b.name)), title);
234
+ }
288
235
 
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
236
  export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
299
237
  const parts: string[] = [`# ${title}`];
300
238
  for (const block of blocks) {
@@ -304,70 +242,35 @@ export function renderStore(blocks: readonly RequirementBlock[], title: string =
304
242
  return `${parts.join("\n\n")}\n`;
305
243
  }
306
244
 
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
-
245
+ export function mergeDelta(storeTextOrBlocks: string | readonly RequirementBlock[], deltaTextOrDelta: string | SpecDelta): MergeResult {
246
+ const store = typeof storeTextOrBlocks === "string" ? parseStore(storeTextOrBlocks) : [...storeTextOrBlocks];
247
+ const delta = typeof deltaTextOrDelta === "string" ? parseDelta(deltaTextOrDelta) : deltaTextOrDelta;
335
248
  const errors = checkGuardrails(store, delta);
336
- if (errors.length > 0) {
337
- return { ok: false, errors };
338
- }
249
+ if (errors.length > 0) return { ok: false, errors };
339
250
 
340
- // Apply the delta. Guardrails have proven every name resolves, so the
341
- // mutations below cannot fail or leave the store half-applied.
342
251
  const removedNames = new Set(delta.removed.map((b) => b.name));
252
+ const renamedByFrom = new Map(delta.renamed.map((b) => [b.from, b]));
343
253
  const modifiedByName = new Map(delta.modified.map((b) => [b.name, b]));
344
254
 
345
255
  const merged: RequirementBlock[] = [];
346
256
  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
257
+ if (removedNames.has(block.name)) continue;
258
+ const rename = renamedByFrom.get(block.name);
259
+ const afterRename = rename ? { name: rename.to, body: rename.body === "" ? block.body : rename.body } : block;
260
+ const replacement = modifiedByName.get(afterRename.name);
261
+ merged.push(replacement ?? afterRename);
353
262
  }
263
+ for (const block of delta.added) merged.push(block);
354
264
 
355
265
  const summary: MergeSummary = {
356
266
  added: delta.added.map((b) => b.name),
357
267
  modified: delta.modified.map((b) => b.name),
358
268
  removed: delta.removed.map((b) => b.name),
269
+ renamed: delta.renamed.map((b) => ({ from: b.from, to: b.to })),
359
270
  };
360
-
361
271
  return { ok: true, store: renderStore(merged), summary };
362
272
  }
363
273
 
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
274
  export function isEmptyDelta(delta: SpecDelta): boolean {
372
275
  return deltaBlockCount(delta) === 0;
373
276
  }
@@ -0,0 +1,44 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { readRunRecords } from "./autotune.ts";
7
+ import { createGitRunner, type GitRunner, type SpawnLike } from "./git-runner.ts";
8
+ import { mergeDelta } from "./spec-merge.ts";
9
+ import { writeLinks } from "./sdd-links.ts";
10
+ import { validateSpecInputs, validateTasksFile } from "./zero-validate.ts";
11
+
12
+ const SDD_DIR = ".sdd";
13
+ type NotifyType = "info" | "warning" | "error";
14
+ interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
15
+ interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
16
+
17
+ function parse(args: string): { slug: string; dryRun: boolean; json: boolean; allowDirty: boolean } { const out = { slug: "", dryRun: false, json: false, allowDirty: false }; for (const p of args.trim().split(/\s+/).filter(Boolean)) { if (p === "--dry-run") out.dryRun = true; else if (p === "--json") out.json = true; else if (p === "--allow-dirty") out.allowDirty = true; else if (!out.slug) out.slug = p; } return out; }
18
+ function notify(ctx: PiCommandContext, message: string, type?: NotifyType) { try { ctx.ui.notify(message, type); } catch {} }
19
+ function latestVerdict(slug: string): string | null { const path = process.env.ZERO_RUNS_PATH || join(homedir(), ".pi", "zero-runs.jsonl"); let latest: { ts: string; verdict: string } | null = null; for (const r of readRunRecords(path)) if (r.feature === slug && (!latest || r.ts > latest.ts)) latest = { ts: r.ts, verdict: r.verdict }; return latest?.verdict ?? null; }
20
+ function today(): string { return new Date().toISOString().slice(0, 10); }
21
+ function read(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
22
+ function atomicWrite(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tmp, content, "utf8"); renameSync(tmp, path); }
23
+ function specPairs(runDir: string): Array<{ domain: string; deltaPath: string; canonicalPath: string }> { const out: Array<{ domain: string; deltaPath: string; canonicalPath: string }> = []; if (existsSync(join(runDir, "spec.md"))) out.push({ domain: "default", deltaPath: join(runDir, "spec.md"), canonicalPath: join(SDD_DIR, "specs", "requirements.md") }); try { const base = join(runDir, "specs"); for (const e of readdirSync(base, { withFileTypes: true })) if (e.isDirectory() && existsSync(join(base, e.name, "spec.md"))) out.push({ domain: e.name, deltaPath: join(base, e.name, "spec.md"), canonicalPath: join(SDD_DIR, "specs", e.name, "requirements.md") }); } catch {} return out; }
24
+
25
+ export async function runZeroArchive(args: string, ctx: PiCommandContext, git: GitRunner = createGitRunner(spawn as unknown as SpawnLike)): Promise<void> {
26
+ const opts = parse(args);
27
+ if (!opts.slug) { notify(ctx, "zero-archive: usage /zero-archive <slug> [--dry-run] [--json] [--allow-dirty]", "warning"); return; }
28
+ const runDir = join(SDD_DIR, opts.slug);
29
+ const emit = (payload: unknown, text: string, type: NotifyType = "info") => notify(ctx, opts.json ? JSON.stringify(payload) : text, type);
30
+ if (!existsSync(runDir)) { emit({ ok: false, reason: "missing-run" }, `zero-archive: no existe ${runDir}`, "error"); return; }
31
+ const verdict = latestVerdict(opts.slug); if (verdict !== "pasa") { emit({ ok: false, reason: "verdict", verdict }, `zero-archive: ${opts.slug} no tiene veredicto pasa (último: ${verdict ?? "—"})`, "error"); return; }
32
+ if (!opts.allowDirty && await git.isDirty()) { emit({ ok: false, reason: "dirty-worktree" }, "zero-archive: worktree sucio; usá --allow-dirty si corresponde", "error"); return; }
33
+ const defects = [...validateSpecInputs(runDir), ...validateTasksFile(read(join(runDir, "tasks.md")))]; if (defects.length) { emit({ ok: false, reason: "validate", defects }, `zero-archive: validación falló\n${defects.map((d) => ` - [${d.kind}] ${d.message}`).join("\n")}`, "error"); return; }
34
+ const pairs = specPairs(runDir); if (!pairs.length) { emit({ ok: false, reason: "missing-spec" }, "zero-archive: no hay spec.md para archivar", "error"); return; }
35
+ const writes: Array<{ path: string; next: string; before: string | null }> = [];
36
+ for (const pair of pairs) { const before = existsSync(pair.canonicalPath) ? read(pair.canonicalPath) : ""; const merged = mergeDelta(before, read(pair.deltaPath)); if (!merged.ok) { emit({ ok: false, reason: "merge", errors: merged.errors }, `zero-archive: merge falló en ${pair.domain}: ${merged.errors.map((e) => e.message).join("; ")}`, "error"); return; } writes.push({ path: pair.canonicalPath, next: merged.store, before: existsSync(pair.canonicalPath) ? before : null }); }
37
+ const archivePath = join(SDD_DIR, "archive", `${today()}-${opts.slug}`);
38
+ if (opts.dryRun) { emit({ ok: true, dryRun: true, archivePath, writes: writes.map((w) => w.path) }, `zero-archive: dry-run archivaría ${opts.slug} en ${archivePath}`); return; }
39
+ const written: typeof writes = [];
40
+ try { for (const w of writes) { atomicWrite(w.path, w.next); written.push(w); } writeLinks(SDD_DIR, opts.slug, { archivePath }); mkdirSync(dirname(archivePath), { recursive: true }); renameSync(runDir, archivePath); emit({ ok: true, archivePath, writes: writes.map((w) => w.path) }, `zero-archive: archivado en ${archivePath}`); }
41
+ catch (err) { for (const w of written.reverse()) { if (w.before === null) rmSync(w.path, { force: true }); else atomicWrite(w.path, w.before); } emit({ ok: false, reason: "rollback", error: err instanceof Error ? err.message : String(err) }, `zero-archive: falló y revertí cambios canónicos: ${err instanceof Error ? err.message : String(err)}`, "error"); }
42
+ }
43
+
44
+ export default function register(pi?: PiExtensionAPI): void { if (!pi || typeof pi.registerCommand !== "function") return; pi.registerCommand("zero-archive", { description: "Mergea el delta aprobado a .sdd/specs y mueve el run a .sdd/archive", handler: (args, ctx) => runZeroArchive(args, ctx).catch((err) => notify(ctx, `zero-archive: ${err instanceof Error ? err.message : String(err)}`, "error")) }); }