@gonrocca/zero-pi 0.1.47 → 0.1.49
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/README.md +49 -1
- package/extensions/gh-runner.ts +77 -0
- package/extensions/pr-body.ts +129 -0
- package/extensions/scan-guard-extension.ts +104 -0
- package/extensions/scan-guard.ts +190 -0
- package/extensions/sdd-links.ts +38 -0
- package/extensions/spec-merge-extension.ts +4 -0
- package/extensions/spec-merge.ts +105 -215
- package/extensions/zero-diff-extension.ts +59 -0
- package/extensions/zero-issue-extension.ts +51 -0
- package/extensions/zero-pr-extension.ts +78 -0
- package/extensions/zero-status-extension.ts +59 -0
- package/extensions/zero-status.ts +65 -0
- package/extensions/zero-validate-extension.ts +65 -0
- package/extensions/zero-validate.ts +133 -0
- package/package.json +22 -3
- package/prompts/phases/explore.md +6 -0
- package/prompts/phases/plan.md +5 -0
- package/prompts/phases/veredicto.md +24 -11
package/extensions/spec-merge.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
34
|
-
| "added-name-collision"
|
|
35
|
-
| "modified-missing"
|
|
36
|
-
| "removed-missing"
|
|
37
|
-
| "
|
|
38
|
-
|
|
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[];
|
|
47
|
-
modified: string[];
|
|
48
|
-
removed: string[];
|
|
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,43 @@ function parseBlocks(lines: readonly string[]): RequirementBlock[] {
|
|
|
100
70
|
}
|
|
101
71
|
}
|
|
102
72
|
flush();
|
|
103
|
-
|
|
104
73
|
return blocks;
|
|
105
74
|
}
|
|
106
75
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
*/
|
|
134
100
|
export function parseDelta(text: string): SpecDelta {
|
|
135
|
-
const delta: SpecDelta = { added: [], modified: [], removed: [] };
|
|
101
|
+
const delta: SpecDelta = { added: [], modified: [], removed: [], renamed: [] };
|
|
136
102
|
if (typeof text !== "string") return delta;
|
|
137
103
|
|
|
138
104
|
let section: keyof SpecDelta | null = null;
|
|
139
105
|
let buffer: string[] = [];
|
|
140
|
-
|
|
141
106
|
const flush = (): void => {
|
|
142
107
|
if (section !== null && buffer.length > 0) {
|
|
143
|
-
delta
|
|
108
|
+
if (section === "renamed") delta.renamed.push(...parseRenameBlocks(buffer));
|
|
109
|
+
else delta[section].push(...parseBlocks(buffer));
|
|
144
110
|
}
|
|
145
111
|
buffer = [];
|
|
146
112
|
};
|
|
@@ -151,150 +117,109 @@ export function parseDelta(text: string): SpecDelta {
|
|
|
151
117
|
flush();
|
|
152
118
|
const keyword = sectionHeader[1].toUpperCase();
|
|
153
119
|
section =
|
|
154
|
-
keyword === "ADDED"
|
|
120
|
+
keyword === "ADDED"
|
|
121
|
+
? "added"
|
|
122
|
+
: keyword === "MODIFIED"
|
|
123
|
+
? "modified"
|
|
124
|
+
: keyword === "REMOVED"
|
|
125
|
+
? "removed"
|
|
126
|
+
: "renamed";
|
|
155
127
|
continue;
|
|
156
128
|
}
|
|
157
129
|
if (section !== null) buffer.push(line);
|
|
158
130
|
}
|
|
159
131
|
flush();
|
|
160
|
-
|
|
161
132
|
return delta;
|
|
162
133
|
}
|
|
163
134
|
|
|
164
|
-
/** Total block count across the three delta buckets. */
|
|
165
135
|
function deltaBlockCount(delta: SpecDelta): number {
|
|
166
|
-
return delta.added.length + delta.modified.length + delta.removed.length;
|
|
136
|
+
return delta.added.length + delta.modified.length + delta.removed.length + delta.renamed.length;
|
|
167
137
|
}
|
|
168
138
|
|
|
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
139
|
export function checkGuardrails(
|
|
196
140
|
store: readonly RequirementBlock[],
|
|
197
141
|
delta: SpecDelta,
|
|
198
142
|
): MergeError[] {
|
|
199
143
|
const errors: MergeError[] = [];
|
|
200
144
|
|
|
201
|
-
// parse-error — any block (store or delta) with an empty name is malformed.
|
|
202
145
|
for (const block of store) {
|
|
203
146
|
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
|
-
});
|
|
147
|
+
errors.push({ kind: "parse-error", name: "", message: "malformed store: a `### REQ:` block has an empty name — the store could not be parsed" });
|
|
210
148
|
break;
|
|
211
149
|
}
|
|
212
150
|
}
|
|
213
151
|
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
214
152
|
if (block.name === "") {
|
|
215
|
-
errors.push({
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
153
|
+
errors.push({ kind: "parse-error", name: "", message: "malformed delta: a `### REQ:` block has an empty name — the delta could not be parsed" });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const block of delta.renamed) {
|
|
158
|
+
if (block.to === "" || block.from === "") {
|
|
159
|
+
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
160
|
break;
|
|
222
161
|
}
|
|
223
162
|
}
|
|
224
163
|
|
|
225
|
-
// duplicate-in-delta — the same name twice across all three delta sections.
|
|
226
164
|
const seen = new Set<string>();
|
|
227
165
|
const flagged = new Set<string>();
|
|
228
166
|
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
229
|
-
if (block.name === "") continue;
|
|
167
|
+
if (block.name === "") continue;
|
|
230
168
|
if (seen.has(block.name)) {
|
|
231
169
|
if (!flagged.has(block.name)) {
|
|
232
170
|
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
|
-
});
|
|
171
|
+
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
172
|
}
|
|
239
|
-
} else
|
|
240
|
-
|
|
173
|
+
} else seen.add(block.name);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const renameSeen = new Set<string>();
|
|
177
|
+
const modifiedNames = new Set(delta.modified.map((b) => b.name));
|
|
178
|
+
for (const block of delta.renamed) {
|
|
179
|
+
if (block.from === "" || block.to === "") continue;
|
|
180
|
+
for (const name of [block.from, block.to]) {
|
|
181
|
+
const allowedRenameThenModify = name === block.to && modifiedNames.has(name);
|
|
182
|
+
if (renameSeen.has(name) || (seen.has(name) && !allowedRenameThenModify)) {
|
|
183
|
+
if (!flagged.has(name)) {
|
|
184
|
+
flagged.add(name);
|
|
185
|
+
errors.push({ kind: "renamed-duplicate", name, message: `RENAMED requirement "${name}" conflicts with another delta entry — rename source and target names must be unique` });
|
|
186
|
+
}
|
|
187
|
+
} else renameSeen.add(name);
|
|
241
188
|
}
|
|
242
189
|
}
|
|
243
190
|
|
|
244
191
|
const storeNames = new Set(store.map((b) => b.name));
|
|
192
|
+
const removedNames = new Set(delta.removed.map((b) => b.name));
|
|
193
|
+
const renameFrom = new Set(delta.renamed.map((b) => b.from).filter(Boolean));
|
|
245
194
|
|
|
246
|
-
// added-name-collision — an ADDED name already exists in the store.
|
|
247
195
|
for (const block of delta.added) {
|
|
248
|
-
if (block.name
|
|
249
|
-
|
|
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
|
-
});
|
|
196
|
+
if (block.name !== "" && storeNames.has(block.name) && !removedNames.has(block.name) && !renameFrom.has(block.name)) {
|
|
197
|
+
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
198
|
}
|
|
256
199
|
}
|
|
257
|
-
|
|
258
|
-
// modified-missing — a MODIFIED name is absent from the store.
|
|
259
200
|
for (const block of delta.modified) {
|
|
260
|
-
if (block.name
|
|
261
|
-
|
|
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
|
-
});
|
|
201
|
+
if (block.name !== "" && !storeNames.has(block.name) && !delta.renamed.some((r) => r.to === block.name)) {
|
|
202
|
+
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
203
|
}
|
|
268
204
|
}
|
|
269
|
-
|
|
270
|
-
// removed-missing — a REMOVED name is absent from the store.
|
|
271
205
|
for (const block of delta.removed) {
|
|
272
|
-
if (block.name
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
206
|
+
if (block.name !== "" && !storeNames.has(block.name)) {
|
|
207
|
+
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` });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const block of delta.renamed) {
|
|
211
|
+
if (block.from === "" || block.to === "") continue;
|
|
212
|
+
if (!storeNames.has(block.from)) {
|
|
213
|
+
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` });
|
|
214
|
+
}
|
|
215
|
+
if (storeNames.has(block.to) && block.to !== block.from && !removedNames.has(block.to) && !renameFrom.has(block.to)) {
|
|
216
|
+
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
217
|
}
|
|
280
218
|
}
|
|
281
219
|
|
|
282
220
|
return errors;
|
|
283
221
|
}
|
|
284
222
|
|
|
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
223
|
export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
299
224
|
const parts: string[] = [`# ${title}`];
|
|
300
225
|
for (const block of blocks) {
|
|
@@ -304,70 +229,35 @@ export function renderStore(blocks: readonly RequirementBlock[], title: string =
|
|
|
304
229
|
return `${parts.join("\n\n")}\n`;
|
|
305
230
|
}
|
|
306
231
|
|
|
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
232
|
export function mergeDelta(storeText: string, deltaText: string): MergeResult {
|
|
332
233
|
const store = parseStore(storeText);
|
|
333
234
|
const delta = parseDelta(deltaText);
|
|
334
|
-
|
|
335
235
|
const errors = checkGuardrails(store, delta);
|
|
336
|
-
if (errors.length > 0) {
|
|
337
|
-
return { ok: false, errors };
|
|
338
|
-
}
|
|
236
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
339
237
|
|
|
340
|
-
// Apply the delta. Guardrails have proven every name resolves, so the
|
|
341
|
-
// mutations below cannot fail or leave the store half-applied.
|
|
342
238
|
const removedNames = new Set(delta.removed.map((b) => b.name));
|
|
239
|
+
const renamedByFrom = new Map(delta.renamed.map((b) => [b.from, b]));
|
|
343
240
|
const modifiedByName = new Map(delta.modified.map((b) => [b.name, b]));
|
|
344
241
|
|
|
345
242
|
const merged: RequirementBlock[] = [];
|
|
346
243
|
for (const block of store) {
|
|
347
|
-
if (removedNames.has(block.name)) continue;
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
merged.push(block); // ADDED — append in delta order
|
|
244
|
+
if (removedNames.has(block.name)) continue;
|
|
245
|
+
const rename = renamedByFrom.get(block.name);
|
|
246
|
+
const afterRename = rename ? { name: rename.to, body: rename.body === "" ? block.body : rename.body } : block;
|
|
247
|
+
const replacement = modifiedByName.get(afterRename.name);
|
|
248
|
+
merged.push(replacement ?? afterRename);
|
|
353
249
|
}
|
|
250
|
+
for (const block of delta.added) merged.push(block);
|
|
354
251
|
|
|
355
252
|
const summary: MergeSummary = {
|
|
356
253
|
added: delta.added.map((b) => b.name),
|
|
357
254
|
modified: delta.modified.map((b) => b.name),
|
|
358
255
|
removed: delta.removed.map((b) => b.name),
|
|
256
|
+
renamed: delta.renamed.map((b) => ({ from: b.from, to: b.to })),
|
|
359
257
|
};
|
|
360
|
-
|
|
361
258
|
return { ok: true, store: renderStore(merged), summary };
|
|
362
259
|
}
|
|
363
260
|
|
|
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
261
|
export function isEmptyDelta(delta: SpecDelta): boolean {
|
|
372
262
|
return deltaBlockCount(delta) === 0;
|
|
373
263
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { isEmptyDelta, mergeDelta, parseDelta, type MergeSummary } from "./spec-merge.ts";
|
|
5
|
+
|
|
6
|
+
const SDD_DIR = ".sdd";
|
|
7
|
+
const STORE_FILE = join(".sdd", "specs", "requirements.md");
|
|
8
|
+
|
|
9
|
+
type NotifyType = "info" | "warning" | "error";
|
|
10
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
11
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
12
|
+
|
|
13
|
+
function readFileOrNull(path: string): string | null { try { return readFileSync(path, "utf8"); } catch { return null; } }
|
|
14
|
+
function resolveSlug(arg: string): string | null {
|
|
15
|
+
const explicit = arg.trim();
|
|
16
|
+
if (explicit) return explicit;
|
|
17
|
+
try {
|
|
18
|
+
const candidates = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive" && existsSync(join(SDD_DIR, e.name, "spec.md"))).map((e) => e.name);
|
|
19
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatDiffReport(summary: MergeSummary): string {
|
|
24
|
+
const lines = ["zero-diff: delta aplicaría estos cambios:"];
|
|
25
|
+
if (summary.added.length) lines.push(` agregados: ${summary.added.join(", ")}`);
|
|
26
|
+
if (summary.modified.length) lines.push(` modificados: ${summary.modified.join(", ")}`);
|
|
27
|
+
if (summary.renamed.length) lines.push(` renombrados: ${summary.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
|
|
28
|
+
if (summary.removed.length) lines.push(` eliminados: ${summary.removed.join(", ")}`);
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runDiff(args: string, ctx: PiCommandContext): void {
|
|
33
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
34
|
+
const slug = resolveSlug(args);
|
|
35
|
+
if (slug === null) { notify("zero-diff: no hay un único run — corré /zero-diff <slug>", "warning"); return; }
|
|
36
|
+
const deltaPath = join(SDD_DIR, slug, "spec.md");
|
|
37
|
+
const deltaText = readFileOrNull(deltaPath);
|
|
38
|
+
if (deltaText === null) { notify(`zero-diff: ${deltaPath} no existe — no hay delta para comparar`, "warning"); return; }
|
|
39
|
+
const storeText = readFileOrNull(STORE_FILE) ?? "";
|
|
40
|
+
const result = mergeDelta(storeText, deltaText);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
const detail = result.errors.map((e) => ` - [${e.kind}] ${e.message}`).join("\n");
|
|
43
|
+
notify(`zero-diff: store NO se podría actualizar — el delta tiene errores de guardrail:\n${detail}`, "error");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (isEmptyDelta(parseDelta(deltaText))) notify(`zero-diff: delta vacío en ${deltaPath} — nada cambiaría`, "info");
|
|
47
|
+
else notify(formatDiffReport(result.summary), "info");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
51
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
52
|
+
pi.registerCommand("zero-diff", {
|
|
53
|
+
description: "Muestra el diff lógico de un spec.md delta sin escribir archivos",
|
|
54
|
+
handler: (args: string, ctx: PiCommandContext): void => {
|
|
55
|
+
try { if (ctx?.ui?.notify) runDiff(args, ctx); }
|
|
56
|
+
catch (err) { try { ctx.ui.notify(`zero-diff: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { buildIssueBody } from "./pr-body.ts";
|
|
7
|
+
import { createGhRunner, type SpawnLike } from "./gh-runner.ts";
|
|
8
|
+
import { writeLinks } from "./sdd-links.ts";
|
|
9
|
+
|
|
10
|
+
const SDD_DIR = ".sdd";
|
|
11
|
+
type NotifyType = "info" | "warning" | "error";
|
|
12
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
13
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
14
|
+
function readFileOrEmpty(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
|
|
15
|
+
function resolveSlug(arg: string): string | null { if (arg.trim()) return arg.trim(); try { const c = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive").map((e) => e.name); return c.length === 1 ? c[0] : null; } catch { return null; } }
|
|
16
|
+
function parseArgs(args: string): { slugArg: string; labels: string[] } { const parts = args.trim().split(/\s+/).filter(Boolean); let slugArg = ""; let type: string | undefined; for (const p of parts) p.startsWith("--type=") ? type = p.slice(7) : slugArg ||= p; return { slugArg, labels: type ? [type] : ["type:feature"] }; }
|
|
17
|
+
function normalizeTitle(s: string): string { return s.toLowerCase().replace(/\s+/g, " ").trim(); }
|
|
18
|
+
async function labelIfExists(gh: ReturnType<typeof createGhRunner>, candidates: string[]): Promise<{ applied: string[]; skipped: string[] }> { const labels = await gh.listLabels(); const existing = new Set(labels.ok ? (labels.data ?? []) : []); return { applied: candidates.filter((l) => existing.has(l)), skipped: candidates.filter((l) => !existing.has(l)) }; }
|
|
19
|
+
function writeTempBody(body: string): string { const file = join(tmpdir(), `zero-issue-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`); writeFileSync(file, body, "utf8"); return file; }
|
|
20
|
+
function skippedNote(skipped: string[]): string { return skipped.length ? `; skipié labels que no existen en este repo: ${skipped.join(", ")}` : ""; }
|
|
21
|
+
|
|
22
|
+
export async function runZeroIssue(args: string, ctx: PiCommandContext, spawnImpl: SpawnLike = spawn as unknown as SpawnLike): Promise<void> {
|
|
23
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
24
|
+
const { slugArg, labels } = parseArgs(args);
|
|
25
|
+
const slug = resolveSlug(slugArg);
|
|
26
|
+
if (slug === null) { notify("zero-issue: no hay un único run — corré /zero-issue <slug>", "warning"); return; }
|
|
27
|
+
const dir = join(SDD_DIR, slug);
|
|
28
|
+
const built = buildIssueBody({ proposalMd: readFileOrEmpty(join(dir, "proposal.md")), specMd: readFileOrEmpty(join(dir, "spec.md")) });
|
|
29
|
+
const gh = createGhRunner({ spawn: spawnImpl });
|
|
30
|
+
const detected = await gh.detect();
|
|
31
|
+
if (!detected.data?.available) { notify(`zero-issue: ${detected.data?.hint ?? "gh CLI no disponible"}`, "error"); return; }
|
|
32
|
+
const dup = await gh.searchIssues(built.title);
|
|
33
|
+
if (!dup.ok) { notify(`zero-issue: gh issue list falló${dup.stderr ? ` — ${dup.stderr}` : ""}`, "error"); return; }
|
|
34
|
+
const existing = dup.data?.find((i) => normalizeTitle(i.title) === normalizeTitle(built.title));
|
|
35
|
+
if (existing) { writeLinks(SDD_DIR, slug, { issueNumber: existing.number, issueUrl: existing.url, createdAt: new Date().toISOString() }); notify(`zero-issue: ya existe el issue #${existing.number}`, "info"); return; }
|
|
36
|
+
const { applied, skipped } = await labelIfExists(gh, labels);
|
|
37
|
+
const tmp = writeTempBody(built.body);
|
|
38
|
+
try {
|
|
39
|
+
const created = await gh.createIssue({ title: built.title, bodyFile: tmp, labels: applied });
|
|
40
|
+
if (!created.ok) { notify(`zero-issue: gh issue create falló${created.stderr ? ` — ${created.stderr}` : ""}`, "error"); return; }
|
|
41
|
+
writeLinks(SDD_DIR, slug, { issueNumber: created.data?.number, issueUrl: created.data?.url, createdAt: new Date().toISOString() });
|
|
42
|
+
notify(`zero-issue: issue creado${created.data?.number ? ` #${created.data.number}` : ""}${created.data?.url ? ` ${created.data.url}` : ""}${skippedNote(skipped)}`, "info");
|
|
43
|
+
} finally {
|
|
44
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
49
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
50
|
+
pi.registerCommand("zero-issue", { description: "Crea o enlaza un issue de GitHub desde .sdd/<slug>", handler: (args, ctx) => runZeroIssue(args, ctx).catch((err) => { try { ctx.ui.notify(`zero-issue: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }) });
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { readRunRecords } from "./autotune.ts";
|
|
7
|
+
import { buildPrBody } from "./pr-body.ts";
|
|
8
|
+
import { createGhRunner, type SpawnLike } from "./gh-runner.ts";
|
|
9
|
+
import { readLinks, writeLinks } from "./sdd-links.ts";
|
|
10
|
+
|
|
11
|
+
const SDD_DIR = ".sdd";
|
|
12
|
+
type NotifyType = "info" | "warning" | "error";
|
|
13
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
14
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
15
|
+
|
|
16
|
+
function readFileOrEmpty(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
|
|
17
|
+
function parseArgs(args: string): { slugArg: string; labels: string[] } {
|
|
18
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
19
|
+
let slugArg = "";
|
|
20
|
+
let type: string | undefined;
|
|
21
|
+
for (const p of parts) p.startsWith("--type=") ? type = p.slice(7) : slugArg ||= p;
|
|
22
|
+
return { slugArg, labels: type ? [type] : ["type:feature", "status:approved"] };
|
|
23
|
+
}
|
|
24
|
+
function resolveSlug(arg: string): string | null {
|
|
25
|
+
if (arg.trim()) return arg.trim();
|
|
26
|
+
try {
|
|
27
|
+
const candidates = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive").map((e) => e.name);
|
|
28
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
function latestVerdict(slug: string): { verdict: string; reasoning?: string } | null {
|
|
32
|
+
const path = process.env.ZERO_RUNS_PATH || join(homedir(), ".pi", "zero-runs.jsonl");
|
|
33
|
+
let latest: { ts: string; verdict: string; reasoning?: string } | null = null;
|
|
34
|
+
for (const r of readRunRecords(path)) if (r.feature === slug && (!latest || r.ts > latest.ts)) latest = { ts: r.ts, verdict: r.verdict, reasoning: (r as any).reasoning ?? (r as any).verdictReasoning };
|
|
35
|
+
return latest;
|
|
36
|
+
}
|
|
37
|
+
async function labelIfExists(gh: ReturnType<typeof createGhRunner>, candidates: string[]): Promise<{ applied: string[]; skipped: string[] }> {
|
|
38
|
+
const labels = await gh.listLabels();
|
|
39
|
+
const existing = new Set(labels.ok ? (labels.data ?? []) : []);
|
|
40
|
+
return { applied: candidates.filter((l) => existing.has(l)), skipped: candidates.filter((l) => !existing.has(l)) };
|
|
41
|
+
}
|
|
42
|
+
function writeTempBody(body: string): string {
|
|
43
|
+
const file = join(tmpdir(), `zero-pr-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
|
|
44
|
+
writeFileSync(file, body, "utf8");
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
function skippedNote(skipped: string[]): string { return skipped.length ? `; skipié labels que no existen en este repo: ${skipped.join(", ")}` : ""; }
|
|
48
|
+
|
|
49
|
+
export async function runZeroPr(args: string, ctx: PiCommandContext, spawnImpl: SpawnLike = spawn as unknown as SpawnLike): Promise<void> {
|
|
50
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
51
|
+
const { slugArg, labels } = parseArgs(args);
|
|
52
|
+
const slug = resolveSlug(slugArg);
|
|
53
|
+
if (slug === null) { notify("zero-pr: no hay un único run — corré /zero-pr <slug>", "warning"); return; }
|
|
54
|
+
const verdict = latestVerdict(slug);
|
|
55
|
+
if (verdict?.verdict !== "pasa") { notify(`zero-pr: ${slug} no tiene veredicto pasa (último: ${verdict?.verdict ?? "—"})`, "warning"); return; }
|
|
56
|
+
const dir = join(SDD_DIR, slug);
|
|
57
|
+
const artifacts = { proposalMd: readFileOrEmpty(join(dir, "proposal.md")), specMd: readFileOrEmpty(join(dir, "spec.md")), tasksMd: readFileOrEmpty(join(dir, "tasks.md")), verdictReasoning: verdict.reasoning };
|
|
58
|
+
const links = readLinks(SDD_DIR, slug);
|
|
59
|
+
const gh = createGhRunner({ spawn: spawnImpl });
|
|
60
|
+
const detected = await gh.detect();
|
|
61
|
+
if (!detected.data?.available) { notify(`zero-pr: ${detected.data?.hint ?? "gh CLI no disponible"}`, "error"); return; }
|
|
62
|
+
const { applied, skipped } = await labelIfExists(gh, labels);
|
|
63
|
+
const built = buildPrBody({ ...artifacts, linkedIssueNumber: typeof links.issueNumber === "number" ? links.issueNumber : undefined });
|
|
64
|
+
const tmp = writeTempBody(built.body);
|
|
65
|
+
try {
|
|
66
|
+
const result = await gh.createPr({ title: built.title, bodyFile: tmp, labels: applied });
|
|
67
|
+
if (!result.ok) { notify(`zero-pr: gh pr create falló${result.stderr ? ` — ${result.stderr}` : ""}`, "error"); return; }
|
|
68
|
+
writeLinks(SDD_DIR, slug, { prNumber: result.data?.number, prUrl: result.data?.url, createdAt: new Date().toISOString() });
|
|
69
|
+
notify(`zero-pr: PR creado${result.data?.number ? ` #${result.data.number}` : ""}${result.data?.url ? ` ${result.data.url}` : ""}${skippedNote(skipped)}`, "info");
|
|
70
|
+
} finally {
|
|
71
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
76
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
77
|
+
pi.registerCommand("zero-pr", { description: "Crea un PR de GitHub desde .sdd/<slug>", handler: (args, ctx) => runZeroPr(args, ctx).catch((err) => { try { ctx.ui.notify(`zero-pr: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }) });
|
|
78
|
+
}
|