@gonrocca/zero-pi 0.1.12 → 0.1.13

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.
@@ -1,286 +1,286 @@
1
- // zero-pi — the /zero-sync command.
2
- //
3
- // A real pi command — a code handler, not an LLM prompt — that folds a
4
- // `/forge` run's delta `spec.md` into the project's canonical spec store and
5
- // archives the change. It is deterministic: every parsing, guardrail, and
6
- // merge decision lives in the pure-logic `spec-merge.ts`; this file only reads
7
- // and writes the filesystem and reports the result.
8
- //
9
- // /zero-sync canonical-specs sync the named run's delta into the store
10
- // /zero-sync resolve the single candidate run, or ask
11
- //
12
- // The SDD orchestrator invokes `/zero-sync <slug>` after a `pasa` verdict. On a
13
- // guardrail failure the command writes NOTHING and reports the failing
14
- // requirement name(s); on success it writes the store atomically (`.tmp` +
15
- // `rename`) and appends an entry to `.sdd/archive/`.
16
- //
17
- // All decisions live in `spec-merge.ts`; the package stays dependency-free:
18
- // `node:fs`/`node:os`/`node:path` only, plus minimal local interfaces for the
19
- // pi API. The whole handler is wrapped in a swallowing `try/catch`, exactly
20
- // like `autotune-extension.ts` — a failure must never break a pi session.
21
-
22
- import {
23
- copyFileSync,
24
- existsSync,
25
- mkdirSync,
26
- readdirSync,
27
- readFileSync,
28
- renameSync,
29
- writeFileSync,
30
- } from "node:fs";
31
- import { join } from "node:path";
32
-
33
- import { isEmptyDelta, mergeDelta, parseDelta, type MergeSummary } from "./spec-merge.ts";
34
-
35
- /** The canonical store path, relative to the project root. */
36
- const STORE_DIR = join(".sdd", "specs");
37
- const STORE_FILE = join(STORE_DIR, "requirements.md");
38
-
39
- /** The per-run artifacts directory root. */
40
- const SDD_DIR = ".sdd";
41
-
42
- /** The audit-trail archive root. */
43
- const ARCHIVE_DIR = join(".sdd", "archive");
44
-
45
- /** Read a UTF-8 file, returning `null` when it is absent or unreadable. */
46
- function readFileOrNull(path: string): string | null {
47
- try {
48
- return readFileSync(path, "utf8");
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- /** Today's date as `YYYY-MM-DD`, for the archive directory name. */
55
- function todayStamp(): string {
56
- return new Date().toISOString().slice(0, 10);
57
- }
58
-
59
- /**
60
- * Resolve the run slug to sync.
61
- *
62
- * The orchestrator always passes an explicit slug, so this is a convenience
63
- * path. With an explicit arg it is used verbatim. With no arg it lists the
64
- * `.sdd/` subdirectories that carry a `spec.md` (sync candidates) and, when
65
- * exactly one exists, returns it; otherwise it returns `null` so the caller
66
- * can ask or report.
67
- */
68
- function resolveSlug(arg: string): string | null {
69
- const explicit = arg.trim();
70
- if (explicit !== "") return explicit;
71
-
72
- let entries: string[];
73
- try {
74
- entries = readdirSync(SDD_DIR, { withFileTypes: true })
75
- .filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive")
76
- .map((e) => e.name);
77
- } catch {
78
- return null;
79
- }
80
-
81
- const candidates = entries.filter((name) => existsSync(join(SDD_DIR, name, "spec.md")));
82
- return candidates.length === 1 ? candidates[0] : null;
83
- }
84
-
85
- /**
86
- * Pick a unique archive directory for `<date>-<slug>`.
87
- *
88
- * The archive is append-only: a new entry never rewrites a prior one. When a
89
- * slug syncs twice on the same date the bare `<date>-<slug>` is already taken,
90
- * so a numeric `-2`, `-3` … suffix is appended until the name is free.
91
- */
92
- function uniqueArchiveDir(date: string, slug: string): string {
93
- const base = `${date}-${slug}`;
94
- let dir = join(ARCHIVE_DIR, base);
95
- let n = 2;
96
- while (existsSync(dir)) {
97
- dir = join(ARCHIVE_DIR, `${base}-${n}`);
98
- n += 1;
99
- }
100
- return dir;
101
- }
102
-
103
- /** Render a `MergeSummary` as the archive `sync.md` audit report. */
104
- function renderSyncReport(slug: string, date: string, summary: MergeSummary): string {
105
- const section = (title: string, names: string[]): string =>
106
- names.length === 0
107
- ? `## ${title}\n\n(none)\n`
108
- : `## ${title}\n\n${names.map((n) => `- ${n}`).join("\n")}\n`;
109
-
110
- return [
111
- `# Spec sync — ${slug}`,
112
- "",
113
- `Date: ${date}`,
114
- "",
115
- section("Added", summary.added),
116
- section("Modified", summary.modified),
117
- section("Removed", summary.removed),
118
- ].join("\n");
119
- }
120
-
121
- /** Render the human-facing one-shot report of a `MergeSummary`. */
122
- function describeSummary(summary: MergeSummary): string {
123
- const lines: string[] = [];
124
- if (summary.added.length > 0) lines.push(` added: ${summary.added.join(", ")}`);
125
- if (summary.modified.length > 0) {
126
- lines.push(` modified: ${summary.modified.join(", ")} (replaces existing spec text)`);
127
- }
128
- if (summary.removed.length > 0) {
129
- lines.push(` removed: ${summary.removed.join(", ")} (deletes from the store)`);
130
- }
131
- return lines.join("\n");
132
- }
133
-
134
- /** The slice of pi's extension API this command uses. */
135
- interface PiUI {
136
- notify(message: string, type?: "info" | "warning" | "error"): void;
137
- }
138
- interface PiCommandContext {
139
- ui: PiUI;
140
- }
141
- interface PiExtensionAPI {
142
- registerCommand(
143
- name: string,
144
- options: {
145
- description?: string;
146
- handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
147
- },
148
- ): void;
149
- }
150
-
151
- /**
152
- * The `/zero-sync` handler — folds a run's delta into the canonical store.
153
- *
154
- * Wrapped in a swallowing `try/catch` so a failure can never break a pi
155
- * session. On a guardrail error or any read failure it writes nothing and
156
- * reports the problem; only a clean `mergeDelta` result reaches the atomic
157
- * store write and the archive step.
158
- */
159
- function runSync(args: string, ctx: PiCommandContext): void {
160
- const notify = (message: string, type?: "info" | "warning" | "error"): void => {
161
- try {
162
- ctx.ui.notify(message, type);
163
- } catch {
164
- // A notification failure must not break the session.
165
- }
166
- };
167
-
168
- const slug = resolveSlug(args);
169
- if (slug === null) {
170
- notify(
171
- "zero-sync: no run slug given and no single sync candidate found — " +
172
- "run /zero-sync <slug> (the slug of the run's .sdd/<slug>/ directory)",
173
- "warning",
174
- );
175
- return;
176
- }
177
-
178
- const deltaPath = join(SDD_DIR, slug, "spec.md");
179
- const deltaText = readFileOrNull(deltaPath);
180
- if (deltaText === null) {
181
- // A legacy run produced no delta `spec.md` — nothing to sync. This is not
182
- // an error: legacy runs finish in legacy mode (additive design).
183
- notify(
184
- `zero-sync: ${deltaPath} not found — this run produced no delta spec.md, nothing to sync`,
185
- "warning",
186
- );
187
- return;
188
- }
189
-
190
- // An empty delta (no blocks in any section) is valid — report and stop
191
- // before touching the store.
192
- if (isEmptyDelta(parseDelta(deltaText))) {
193
- notify(`zero-sync: empty delta in ${deltaPath} — nothing to sync, store left untouched`, "info");
194
- return;
195
- }
196
-
197
- // The store is absent on a fresh project — `mergeDelta` treats "" as the
198
- // empty store, so an all-ADDED delta bootstraps it.
199
- const storeText = readFileOrNull(STORE_FILE) ?? "";
200
-
201
- const result = mergeDelta(storeText, deltaText);
202
- if (!result.ok) {
203
- // Guardrail failure — write NOTHING. The `pasa` verdict still stands, but
204
- // the store is flagged out of sync for a manual fix.
205
- const detail = result.errors.map((e) => ` - [${e.kind}] ${e.message}`).join("\n");
206
- notify(
207
- `zero-sync: store NOT updated — the delta has guardrail errors:\n${detail}`,
208
- "error",
209
- );
210
- return;
211
- }
212
-
213
- // Success — write the store atomically: `.tmp` then `rename`. `mkdir -p`
214
- // `.sdd/specs/` so a fresh project gets its first store file.
215
- try {
216
- mkdirSync(STORE_DIR, { recursive: true });
217
- const tmp = `${STORE_FILE}.tmp`;
218
- writeFileSync(tmp, result.store, "utf8");
219
- renameSync(tmp, STORE_FILE);
220
- } catch (err) {
221
- notify(
222
- `zero-sync: failed to write the store at ${STORE_FILE}: ${
223
- err instanceof Error ? err.message : String(err)
224
- }`,
225
- "error",
226
- );
227
- return;
228
- }
229
-
230
- // Archive step. The store is already the source of truth; an archive failure
231
- // here does NOT revert the merge — it is reported so the gap is visible.
232
- const date = todayStamp();
233
- let archiveNote = "";
234
- try {
235
- const archiveDir = uniqueArchiveDir(date, slug);
236
- mkdirSync(archiveDir, { recursive: true });
237
-
238
- for (const name of ["proposal.md", "spec.md"]) {
239
- const src = join(SDD_DIR, slug, name);
240
- if (existsSync(src)) copyFileSync(src, join(archiveDir, name));
241
- }
242
- writeFileSync(join(archiveDir, "sync.md"), renderSyncReport(slug, date, result.summary), "utf8");
243
- archiveNote = `archived to ${archiveDir}`;
244
- } catch (err) {
245
- archiveNote = `WARNING: store updated but archive step failed: ${
246
- err instanceof Error ? err.message : String(err)
247
- }`;
248
- }
249
-
250
- const detail = describeSummary(result.summary);
251
- notify(
252
- `zero-sync: canonical store updated (${STORE_FILE})\n${detail}\n${archiveNote}`,
253
- "info",
254
- );
255
- }
256
-
257
- /**
258
- * The pi extension entry point — registers the `/zero-sync` command.
259
- *
260
- * Called with no or an invalid `pi`, it no-ops cleanly. The handler body is
261
- * wrapped again in a swallowing `try/catch` so neither registration nor a
262
- * later failure can break a pi session.
263
- */
264
- export default function register(pi?: PiExtensionAPI): void {
265
- if (!pi || typeof pi.registerCommand !== "function") return;
266
-
267
- pi.registerCommand("zero-sync", {
268
- description:
269
- "Fold a /forge run's delta spec.md into the canonical .sdd/specs/ store — /zero-sync <slug>",
270
- handler: (args: string, ctx: PiCommandContext): void => {
271
- try {
272
- if (!ctx || !ctx.ui || typeof ctx.ui.notify !== "function") return;
273
- runSync(args, ctx);
274
- } catch (err) {
275
- try {
276
- ctx.ui.notify(
277
- `zero-sync: ${err instanceof Error ? err.message : String(err)}`,
278
- "error",
279
- );
280
- } catch {
281
- // A notification failure must never break a pi session.
282
- }
283
- }
284
- },
285
- });
286
- }
1
+ // zero-pi — the /zero-sync command.
2
+ //
3
+ // A real pi command — a code handler, not an LLM prompt — that folds a
4
+ // `/forge` run's delta `spec.md` into the project's canonical spec store and
5
+ // archives the change. It is deterministic: every parsing, guardrail, and
6
+ // merge decision lives in the pure-logic `spec-merge.ts`; this file only reads
7
+ // and writes the filesystem and reports the result.
8
+ //
9
+ // /zero-sync canonical-specs sync the named run's delta into the store
10
+ // /zero-sync resolve the single candidate run, or ask
11
+ //
12
+ // The SDD orchestrator invokes `/zero-sync <slug>` after a `pasa` verdict. On a
13
+ // guardrail failure the command writes NOTHING and reports the failing
14
+ // requirement name(s); on success it writes the store atomically (`.tmp` +
15
+ // `rename`) and appends an entry to `.sdd/archive/`.
16
+ //
17
+ // All decisions live in `spec-merge.ts`; the package stays dependency-free:
18
+ // `node:fs`/`node:os`/`node:path` only, plus minimal local interfaces for the
19
+ // pi API. The whole handler is wrapped in a swallowing `try/catch`, exactly
20
+ // like `autotune-extension.ts` — a failure must never break a pi session.
21
+
22
+ import {
23
+ copyFileSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ renameSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import { join } from "node:path";
32
+
33
+ import { isEmptyDelta, mergeDelta, parseDelta, type MergeSummary } from "./spec-merge.ts";
34
+
35
+ /** The canonical store path, relative to the project root. */
36
+ const STORE_DIR = join(".sdd", "specs");
37
+ const STORE_FILE = join(STORE_DIR, "requirements.md");
38
+
39
+ /** The per-run artifacts directory root. */
40
+ const SDD_DIR = ".sdd";
41
+
42
+ /** The audit-trail archive root. */
43
+ const ARCHIVE_DIR = join(".sdd", "archive");
44
+
45
+ /** Read a UTF-8 file, returning `null` when it is absent or unreadable. */
46
+ function readFileOrNull(path: string): string | null {
47
+ try {
48
+ return readFileSync(path, "utf8");
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Today's date as `YYYY-MM-DD`, for the archive directory name. */
55
+ function todayStamp(): string {
56
+ return new Date().toISOString().slice(0, 10);
57
+ }
58
+
59
+ /**
60
+ * Resolve the run slug to sync.
61
+ *
62
+ * The orchestrator always passes an explicit slug, so this is a convenience
63
+ * path. With an explicit arg it is used verbatim. With no arg it lists the
64
+ * `.sdd/` subdirectories that carry a `spec.md` (sync candidates) and, when
65
+ * exactly one exists, returns it; otherwise it returns `null` so the caller
66
+ * can ask or report.
67
+ */
68
+ function resolveSlug(arg: string): string | null {
69
+ const explicit = arg.trim();
70
+ if (explicit !== "") return explicit;
71
+
72
+ let entries: string[];
73
+ try {
74
+ entries = readdirSync(SDD_DIR, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive")
76
+ .map((e) => e.name);
77
+ } catch {
78
+ return null;
79
+ }
80
+
81
+ const candidates = entries.filter((name) => existsSync(join(SDD_DIR, name, "spec.md")));
82
+ return candidates.length === 1 ? candidates[0] : null;
83
+ }
84
+
85
+ /**
86
+ * Pick a unique archive directory for `<date>-<slug>`.
87
+ *
88
+ * The archive is append-only: a new entry never rewrites a prior one. When a
89
+ * slug syncs twice on the same date the bare `<date>-<slug>` is already taken,
90
+ * so a numeric `-2`, `-3` … suffix is appended until the name is free.
91
+ */
92
+ function uniqueArchiveDir(date: string, slug: string): string {
93
+ const base = `${date}-${slug}`;
94
+ let dir = join(ARCHIVE_DIR, base);
95
+ let n = 2;
96
+ while (existsSync(dir)) {
97
+ dir = join(ARCHIVE_DIR, `${base}-${n}`);
98
+ n += 1;
99
+ }
100
+ return dir;
101
+ }
102
+
103
+ /** Render a `MergeSummary` as the archive `sync.md` audit report. */
104
+ function renderSyncReport(slug: string, date: string, summary: MergeSummary): string {
105
+ const section = (title: string, names: string[]): string =>
106
+ names.length === 0
107
+ ? `## ${title}\n\n(none)\n`
108
+ : `## ${title}\n\n${names.map((n) => `- ${n}`).join("\n")}\n`;
109
+
110
+ return [
111
+ `# Spec sync — ${slug}`,
112
+ "",
113
+ `Date: ${date}`,
114
+ "",
115
+ section("Added", summary.added),
116
+ section("Modified", summary.modified),
117
+ section("Removed", summary.removed),
118
+ ].join("\n");
119
+ }
120
+
121
+ /** Render the human-facing one-shot report of a `MergeSummary`. */
122
+ function describeSummary(summary: MergeSummary): string {
123
+ const lines: string[] = [];
124
+ if (summary.added.length > 0) lines.push(` added: ${summary.added.join(", ")}`);
125
+ if (summary.modified.length > 0) {
126
+ lines.push(` modified: ${summary.modified.join(", ")} (replaces existing spec text)`);
127
+ }
128
+ if (summary.removed.length > 0) {
129
+ lines.push(` removed: ${summary.removed.join(", ")} (deletes from the store)`);
130
+ }
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /** The slice of pi's extension API this command uses. */
135
+ interface PiUI {
136
+ notify(message: string, type?: "info" | "warning" | "error"): void;
137
+ }
138
+ interface PiCommandContext {
139
+ ui: PiUI;
140
+ }
141
+ interface PiExtensionAPI {
142
+ registerCommand(
143
+ name: string,
144
+ options: {
145
+ description?: string;
146
+ handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
147
+ },
148
+ ): void;
149
+ }
150
+
151
+ /**
152
+ * The `/zero-sync` handler — folds a run's delta into the canonical store.
153
+ *
154
+ * Wrapped in a swallowing `try/catch` so a failure can never break a pi
155
+ * session. On a guardrail error or any read failure it writes nothing and
156
+ * reports the problem; only a clean `mergeDelta` result reaches the atomic
157
+ * store write and the archive step.
158
+ */
159
+ function runSync(args: string, ctx: PiCommandContext): void {
160
+ const notify = (message: string, type?: "info" | "warning" | "error"): void => {
161
+ try {
162
+ ctx.ui.notify(message, type);
163
+ } catch {
164
+ // A notification failure must not break the session.
165
+ }
166
+ };
167
+
168
+ const slug = resolveSlug(args);
169
+ if (slug === null) {
170
+ notify(
171
+ "zero-sync: no run slug given and no single sync candidate found — " +
172
+ "run /zero-sync <slug> (the slug of the run's .sdd/<slug>/ directory)",
173
+ "warning",
174
+ );
175
+ return;
176
+ }
177
+
178
+ const deltaPath = join(SDD_DIR, slug, "spec.md");
179
+ const deltaText = readFileOrNull(deltaPath);
180
+ if (deltaText === null) {
181
+ // A legacy run produced no delta `spec.md` — nothing to sync. This is not
182
+ // an error: legacy runs finish in legacy mode (additive design).
183
+ notify(
184
+ `zero-sync: ${deltaPath} not found — this run produced no delta spec.md, nothing to sync`,
185
+ "warning",
186
+ );
187
+ return;
188
+ }
189
+
190
+ // An empty delta (no blocks in any section) is valid — report and stop
191
+ // before touching the store.
192
+ if (isEmptyDelta(parseDelta(deltaText))) {
193
+ notify(`zero-sync: empty delta in ${deltaPath} — nothing to sync, store left untouched`, "info");
194
+ return;
195
+ }
196
+
197
+ // The store is absent on a fresh project — `mergeDelta` treats "" as the
198
+ // empty store, so an all-ADDED delta bootstraps it.
199
+ const storeText = readFileOrNull(STORE_FILE) ?? "";
200
+
201
+ const result = mergeDelta(storeText, deltaText);
202
+ if (!result.ok) {
203
+ // Guardrail failure — write NOTHING. The `pasa` verdict still stands, but
204
+ // the store is flagged out of sync for a manual fix.
205
+ const detail = result.errors.map((e) => ` - [${e.kind}] ${e.message}`).join("\n");
206
+ notify(
207
+ `zero-sync: store NOT updated — the delta has guardrail errors:\n${detail}`,
208
+ "error",
209
+ );
210
+ return;
211
+ }
212
+
213
+ // Success — write the store atomically: `.tmp` then `rename`. `mkdir -p`
214
+ // `.sdd/specs/` so a fresh project gets its first store file.
215
+ try {
216
+ mkdirSync(STORE_DIR, { recursive: true });
217
+ const tmp = `${STORE_FILE}.tmp`;
218
+ writeFileSync(tmp, result.store, "utf8");
219
+ renameSync(tmp, STORE_FILE);
220
+ } catch (err) {
221
+ notify(
222
+ `zero-sync: failed to write the store at ${STORE_FILE}: ${
223
+ err instanceof Error ? err.message : String(err)
224
+ }`,
225
+ "error",
226
+ );
227
+ return;
228
+ }
229
+
230
+ // Archive step. The store is already the source of truth; an archive failure
231
+ // here does NOT revert the merge — it is reported so the gap is visible.
232
+ const date = todayStamp();
233
+ let archiveNote = "";
234
+ try {
235
+ const archiveDir = uniqueArchiveDir(date, slug);
236
+ mkdirSync(archiveDir, { recursive: true });
237
+
238
+ for (const name of ["proposal.md", "spec.md"]) {
239
+ const src = join(SDD_DIR, slug, name);
240
+ if (existsSync(src)) copyFileSync(src, join(archiveDir, name));
241
+ }
242
+ writeFileSync(join(archiveDir, "sync.md"), renderSyncReport(slug, date, result.summary), "utf8");
243
+ archiveNote = `archived to ${archiveDir}`;
244
+ } catch (err) {
245
+ archiveNote = `WARNING: store updated but archive step failed: ${
246
+ err instanceof Error ? err.message : String(err)
247
+ }`;
248
+ }
249
+
250
+ const detail = describeSummary(result.summary);
251
+ notify(
252
+ `zero-sync: canonical store updated (${STORE_FILE})\n${detail}\n${archiveNote}`,
253
+ "info",
254
+ );
255
+ }
256
+
257
+ /**
258
+ * The pi extension entry point — registers the `/zero-sync` command.
259
+ *
260
+ * Called with no or an invalid `pi`, it no-ops cleanly. The handler body is
261
+ * wrapped again in a swallowing `try/catch` so neither registration nor a
262
+ * later failure can break a pi session.
263
+ */
264
+ export default function register(pi?: PiExtensionAPI): void {
265
+ if (!pi || typeof pi.registerCommand !== "function") return;
266
+
267
+ pi.registerCommand("zero-sync", {
268
+ description:
269
+ "Fold a /forge run's delta spec.md into the canonical .sdd/specs/ store — /zero-sync <slug>",
270
+ handler: (args: string, ctx: PiCommandContext): void => {
271
+ try {
272
+ if (!ctx || !ctx.ui || typeof ctx.ui.notify !== "function") return;
273
+ runSync(args, ctx);
274
+ } catch (err) {
275
+ try {
276
+ ctx.ui.notify(
277
+ `zero-sync: ${err instanceof Error ? err.message : String(err)}`,
278
+ "error",
279
+ );
280
+ } catch {
281
+ // A notification failure must never break a pi session.
282
+ }
283
+ }
284
+ },
285
+ });
286
+ }