@gonrocca/zero-pi 0.1.7 → 0.1.10
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 +62 -5
- package/extensions/autotune.ts +177 -42
- package/extensions/spec-merge-extension.ts +286 -0
- package/extensions/spec-merge.ts +373 -0
- package/package.json +5 -2
- package/prompts/orchestrator.md +47 -2
- package/prompts/phases/plan.md +44 -7
- package/skills/sdd-routing.md +54 -0
package/README.md
CHANGED
|
@@ -46,6 +46,12 @@ Run it with the `/forge <feature>` prompt. The orchestrator drives phase order
|
|
|
46
46
|
and enforces a hard build/veredicto iteration cap. Each phase has its own prompt
|
|
47
47
|
under `prompts/phases/` so it can be delegated to a dedicated sub-agent.
|
|
48
48
|
|
|
49
|
+
Besides the explicit `/forge` command, an SDD run can also be started from
|
|
50
|
+
natural language: describe the work and signal SDD intent — e.g. "hacelo con
|
|
51
|
+
sdd" or "usá el pipeline" — and the `sdd-routing` skill routes the request into
|
|
52
|
+
`/forge` for you. It triggers only on a clear signal phrase and stays out of the
|
|
53
|
+
way for ordinary requests; `/forge` remains the primary, explicit entry point.
|
|
54
|
+
|
|
49
55
|
**Review Workload Forecast** — the plan phase keeps tasks reviewable. Every
|
|
50
56
|
planned task carries a `review: ~N changed lines` estimate, and `tasks.md` gains
|
|
51
57
|
a `## Review Workload` section with the per-task estimates and a bold run total.
|
|
@@ -80,6 +86,47 @@ correction rounds, and the gotchas — under `topic_key: zero-run/<slug>`. The
|
|
|
80
86
|
next run on related work starts from what the last one learned. With `--no-mcp`
|
|
81
87
|
the loop degrades silently.
|
|
82
88
|
|
|
89
|
+
### Canonical specs & `/zero-sync`
|
|
90
|
+
|
|
91
|
+
zero keeps a **canonical, project-wide spec store** that accumulates accepted
|
|
92
|
+
requirements across runs, so each `/forge` run builds on the last instead of
|
|
93
|
+
starting from a blank spec.
|
|
94
|
+
|
|
95
|
+
**The canonical store — `.sdd/specs/requirements.md`.** A single flat markdown
|
|
96
|
+
file: a `# ` title followed by `### REQ: <stable-unique-name>` requirement
|
|
97
|
+
blocks. It is the project's source of truth. The `plan` phase reads it as the
|
|
98
|
+
baseline; a fresh project has no store yet, and that absence simply means an
|
|
99
|
+
empty store.
|
|
100
|
+
|
|
101
|
+
**The granular plan artifacts.** Every run's `plan` phase writes four files
|
|
102
|
+
into `.sdd/<slug>/`:
|
|
103
|
+
|
|
104
|
+
- `proposal.md` — the change intent: scope and rationale, in prose.
|
|
105
|
+
- `spec.md` — the **delta** against the canonical store, never a full spec. Up
|
|
106
|
+
to three `H2` sections — `## ADDED`, `## MODIFIED`, `## REMOVED` — each
|
|
107
|
+
holding `### REQ:` blocks. `## MODIFIED` carries the complete updated text of
|
|
108
|
+
an existing block (not a diff); `## REMOVED` needs only the name line.
|
|
109
|
+
- `design.md` — how it is built.
|
|
110
|
+
- `tasks.md` — the ordered task list with its `## Review Workload` section.
|
|
111
|
+
|
|
112
|
+
**`/zero-sync` — folding the delta into the store.** `/zero-sync <slug>` is a
|
|
113
|
+
real pi command — a deterministic, unit-tested merge, not an LLM prompt — that
|
|
114
|
+
reads the store and the run's delta `spec.md`, applies the ADDED/MODIFIED/REMOVED
|
|
115
|
+
changes, and writes the store atomically. Guardrails reject a bad delta before
|
|
116
|
+
anything is written: a duplicate name, an ADDED collision with an existing
|
|
117
|
+
block, a MODIFIED or REMOVED of a missing block, or malformed input. On a
|
|
118
|
+
guardrail failure it writes nothing and reports the offending requirement; the
|
|
119
|
+
store is never left half-merged. After a `pasa` verdict the SDD orchestrator
|
|
120
|
+
invokes `/zero-sync <slug>` automatically — a `corregir`, `replantear`, or
|
|
121
|
+
cap-reached run never syncs.
|
|
122
|
+
|
|
123
|
+
**The archive — `.sdd/archive/`.** Each successful sync appends a dated,
|
|
124
|
+
slug-named entry `.sdd/archive/<YYYY-MM-DD>-<slug>/` containing a copy of the
|
|
125
|
+
run's `proposal.md` and `spec.md` plus a `sync.md` report listing every added,
|
|
126
|
+
modified, and removed requirement. The archive is append-only — a new entry
|
|
127
|
+
never rewrites a prior one — so it is a full audit trail of how the canonical
|
|
128
|
+
store evolved.
|
|
129
|
+
|
|
83
130
|
### Adaptive model profiles
|
|
84
131
|
|
|
85
132
|
zero learns which model fits each SDD phase from your own run history and can
|
|
@@ -87,14 +134,24 @@ re-tune `~/.pi/zero.json` for you.
|
|
|
87
134
|
|
|
88
135
|
**The metrics log — `~/.pi/zero-runs.jsonl`.** Every completed SDD run appends
|
|
89
136
|
one JSON line to this file: the feature slug, the per-phase models the run used,
|
|
90
|
-
the final verdict,
|
|
91
|
-
never rewritten. This local log is the
|
|
92
|
-
abandoned before it reaches a verdict adds
|
|
137
|
+
the final verdict, the build/veredicto round count, and the ordered per-round
|
|
138
|
+
verdict sequence. It is append-only and never rewritten. This local log is the
|
|
139
|
+
only thing zero learns from — a run abandoned before it reaches a verdict adds
|
|
140
|
+
no line.
|
|
141
|
+
|
|
142
|
+
**Phase attribution (v2).** The per-round verdict sequence makes blame precise:
|
|
143
|
+
a `corregir` round re-runs — and so blames — the `build` phase, and a
|
|
144
|
+
`replantear` round blames the `plan` phase. Autotune aggregates that sequence
|
|
145
|
+
per phase and upgrades **only the phase at fault**, one tier at a time — a
|
|
146
|
+
`build` problem no longer drags `plan` up with it. The `explore` and `veredicto`
|
|
147
|
+
phases are never tuned.
|
|
93
148
|
|
|
94
149
|
**Autotune modes.** At each pi session start zero aggregates the log and, once a
|
|
95
150
|
phase has accumulated enough run data to cross a confidence threshold, decides
|
|
96
|
-
whether that phase's model should change.
|
|
97
|
-
|
|
151
|
+
whether that phase's model should change. Until a phase has accumulated enough
|
|
152
|
+
v2 runs of its own, autotune deliberately stays quiet — a one-time, silent
|
|
153
|
+
cold-start after upgrading, not a regression. The `autotune` mode in
|
|
154
|
+
`~/.pi/zero.json` controls what happens next:
|
|
98
155
|
|
|
99
156
|
| Mode | Behaviour |
|
|
100
157
|
| ---- | --------- |
|
package/extensions/autotune.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
|
|
14
14
|
/** Schema version of one `~/.pi/zero-runs.jsonl` record. A record carrying any
|
|
15
|
-
* other `v` is dropped by `parseRunLine` rather than mis-aggregated.
|
|
16
|
-
|
|
15
|
+
* other `v` is dropped by `parseRunLine` rather than mis-aggregated. v2 adds
|
|
16
|
+
* the per-round `verdicts` sequence; `parseRunLine` still accepts v1 records. */
|
|
17
|
+
export const RUN_SCHEMA_VERSION = 2;
|
|
17
18
|
|
|
18
19
|
/** The SDD phases a run record carries a model for, in pipeline order. */
|
|
19
20
|
const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
@@ -25,6 +26,14 @@ const VERDICTS = ["pasa", "cap-reached"] as const;
|
|
|
25
26
|
/** A run's terminal verdict. */
|
|
26
27
|
export type RunVerdict = (typeof VERDICTS)[number];
|
|
27
28
|
|
|
29
|
+
/** Per-round verdicts the `veredicto` phase can return. `corregir` re-runs
|
|
30
|
+
* build, `replantear` re-runs plan, `pasa` ends the run. `cap-reached` is a
|
|
31
|
+
* run-level terminal state and is deliberately *not* a round verdict. */
|
|
32
|
+
const ROUND_VERDICTS = ["corregir", "replantear", "pasa"] as const;
|
|
33
|
+
|
|
34
|
+
/** A single round's verdict from the `veredicto` phase. */
|
|
35
|
+
export type RunRoundVerdict = (typeof ROUND_VERDICTS)[number];
|
|
36
|
+
|
|
28
37
|
/** The model a single phase ran on for a given run. An object (not a bare
|
|
29
38
|
* string) so the schema can grow additive fields without a version bump. */
|
|
30
39
|
export interface PhaseRun {
|
|
@@ -48,6 +57,11 @@ export interface RunRecord {
|
|
|
48
57
|
verdict: RunVerdict;
|
|
49
58
|
/** Count of build/veredicto rounds — `1` for a clean first-pass run. */
|
|
50
59
|
rounds: number;
|
|
60
|
+
/** The chronological per-round verdict sequence. Present on `v:2` records
|
|
61
|
+
* (`verdicts.length === rounds`); `undefined` on `v:1` records, which carry
|
|
62
|
+
* only the run-level verdict and so cannot be phase-attributed. Its
|
|
63
|
+
* presence is the single discriminator `aggregate` uses for v2 attribution. */
|
|
64
|
+
verdicts?: RunRoundVerdict[];
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/** How aggressively zero applies a learned profile — stored in `zero.json`.
|
|
@@ -60,15 +74,56 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
60
74
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Validate a parsed `verdicts` value against the v2 invariants.
|
|
79
|
+
*
|
|
80
|
+
* Returns the typed array when the value is a non-empty `RunRoundVerdict[]`
|
|
81
|
+
* with `length === rounds` and a sequence consistent with the run-level
|
|
82
|
+
* `verdict`: a `pasa` run ends with exactly one `pasa` (last entry), a
|
|
83
|
+
* `cap-reached` run contains no `pasa`. Returns `null` for any violation —
|
|
84
|
+
* the caller drops the whole record rather than mis-attribute.
|
|
85
|
+
*/
|
|
86
|
+
function validateVerdicts(
|
|
87
|
+
value: unknown,
|
|
88
|
+
verdict: RunVerdict,
|
|
89
|
+
rounds: number,
|
|
90
|
+
): RunRoundVerdict[] | null {
|
|
91
|
+
if (!Array.isArray(value)) return null;
|
|
92
|
+
if (value.length < 1) return null;
|
|
93
|
+
if (value.length !== rounds) return null;
|
|
94
|
+
|
|
95
|
+
const verdicts: RunRoundVerdict[] = [];
|
|
96
|
+
for (const entry of value) {
|
|
97
|
+
if (typeof entry !== "string") return null;
|
|
98
|
+
if (!(ROUND_VERDICTS as readonly string[]).includes(entry)) return null;
|
|
99
|
+
verdicts.push(entry as RunRoundVerdict);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const passCount = verdicts.filter((e) => e === "pasa").length;
|
|
103
|
+
if (verdict === "pasa") {
|
|
104
|
+
// Exactly one `pasa`, and it is the final entry.
|
|
105
|
+
if (passCount !== 1) return null;
|
|
106
|
+
if (verdicts[verdicts.length - 1] !== "pasa") return null;
|
|
107
|
+
} else {
|
|
108
|
+
// `cap-reached`: the run never passed.
|
|
109
|
+
if (passCount !== 0) return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return verdicts;
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
/**
|
|
64
116
|
* Parse one JSONL line into a `RunRecord`, validating the shape.
|
|
65
117
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
118
|
+
* Accepts both `v:1` records (no `verdicts`) and `v:2` records (a fully
|
|
119
|
+
* validated per-round `verdicts` sequence). Returns `null` — never throws —
|
|
120
|
+
* for anything off-shape: invalid JSON, a non-object, a `v` outside
|
|
121
|
+
* `{1, 2}`, a missing/non-string `feature` or `ts`, a non-integer `rounds`, a
|
|
122
|
+
* `verdict` outside the enum, a `phases` map missing any of the four phases
|
|
123
|
+
* or carrying a non-string model, or — for `v:2` — a missing/non-array/
|
|
124
|
+
* inconsistent `verdicts`. This is the deliberate defense against malformed
|
|
125
|
+
* LLM-emitted records: a bad emission degrades to "one missing sample",
|
|
126
|
+
* never a crash.
|
|
72
127
|
*/
|
|
73
128
|
export function parseRunLine(line: string): RunRecord | null {
|
|
74
129
|
let parsed: unknown;
|
|
@@ -80,12 +135,13 @@ export function parseRunLine(line: string): RunRecord | null {
|
|
|
80
135
|
|
|
81
136
|
if (!isObject(parsed)) return null;
|
|
82
137
|
|
|
83
|
-
if (parsed.v !==
|
|
138
|
+
if (parsed.v !== 1 && parsed.v !== 2) return null;
|
|
84
139
|
if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
|
|
85
140
|
if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
|
|
86
141
|
if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
|
|
87
142
|
if (typeof parsed.verdict !== "string") return null;
|
|
88
143
|
if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
|
|
144
|
+
const verdict = parsed.verdict as RunVerdict;
|
|
89
145
|
|
|
90
146
|
if (!isObject(parsed.phases)) return null;
|
|
91
147
|
const phases = {} as RunRecord["phases"];
|
|
@@ -96,13 +152,23 @@ export function parseRunLine(line: string): RunRecord | null {
|
|
|
96
152
|
phases[phase] = { model: phaseRun.model };
|
|
97
153
|
}
|
|
98
154
|
|
|
155
|
+
// v2 records must carry a fully consistent `verdicts` sequence; v1 records
|
|
156
|
+
// legitimately lack the field (the discriminator is `v`, not its presence).
|
|
157
|
+
let verdicts: RunRoundVerdict[] | undefined;
|
|
158
|
+
if (parsed.v === 2) {
|
|
159
|
+
const validated = validateVerdicts(parsed.verdicts, verdict, parsed.rounds);
|
|
160
|
+
if (validated === null) return null;
|
|
161
|
+
verdicts = validated;
|
|
162
|
+
}
|
|
163
|
+
|
|
99
164
|
return {
|
|
100
|
-
v:
|
|
165
|
+
v: parsed.v,
|
|
101
166
|
ts: parsed.ts,
|
|
102
167
|
feature: parsed.feature,
|
|
103
168
|
phases,
|
|
104
|
-
verdict
|
|
169
|
+
verdict,
|
|
105
170
|
rounds: parsed.rounds,
|
|
171
|
+
...(verdicts !== undefined ? { verdicts } : {}),
|
|
106
172
|
};
|
|
107
173
|
}
|
|
108
174
|
|
|
@@ -149,6 +215,16 @@ export interface PhaseModelStat {
|
|
|
149
215
|
/** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
|
|
150
216
|
* `pasa` run exists for the pair. */
|
|
151
217
|
avgRounds: number | null;
|
|
218
|
+
// --- v2 phase attribution ---
|
|
219
|
+
/** Count of `v:2` records contributing to this pair — the dormancy-gate
|
|
220
|
+
* denominator. v1 records do not increment this. */
|
|
221
|
+
v2Samples: number;
|
|
222
|
+
/** Mean count of `corregir` verdicts per v2 run for this pair — only
|
|
223
|
+
* meaningful for `phase === "build"`. `null` when `v2Samples === 0`. */
|
|
224
|
+
avgCorregir: number | null;
|
|
225
|
+
/** Mean count of `replantear` verdicts per v2 run for this pair — only
|
|
226
|
+
* meaningful for `phase === "plan"`. `null` when `v2Samples === 0`. */
|
|
227
|
+
avgReplantear: number | null;
|
|
152
228
|
}
|
|
153
229
|
|
|
154
230
|
/** Map key for a `(phase, model)` bucket. */
|
|
@@ -159,12 +235,19 @@ function statKey(phase: string, model: string): string {
|
|
|
159
235
|
/**
|
|
160
236
|
* Aggregate run records into per-`(phase, model)` statistics.
|
|
161
237
|
*
|
|
162
|
-
* For every record, each of the four phases contributes one
|
|
163
|
-
* bucket of the model that phase ran on (a phase whose model is
|
|
164
|
-
* non-string is skipped). `avgRounds` is averaged exclusively over
|
|
165
|
-
* reached `pasa`; non-positive `rounds` values (which `parseRunLine`
|
|
166
|
-
* reject) are ignored when computing that average so a bad
|
|
167
|
-
* never skews or breaks the math.
|
|
238
|
+
* For every record (v1 and v2 alike), each of the four phases contributes one
|
|
239
|
+
* sample to the bucket of the model that phase ran on (a phase whose model is
|
|
240
|
+
* missing or non-string is skipped). `avgRounds` is averaged exclusively over
|
|
241
|
+
* runs that reached `pasa`; non-positive `rounds` values (which `parseRunLine`
|
|
242
|
+
* does not reject) are ignored when computing that average so a bad
|
|
243
|
+
* `0`/negative entry never skews or breaks the math.
|
|
244
|
+
*
|
|
245
|
+
* Only records carrying a `verdicts` sequence (v2) contribute phase
|
|
246
|
+
* attribution: each such record increments `v2Samples` for every phase, and
|
|
247
|
+
* folds its per-run `corregir` count into the `build` model's `avgCorregir`
|
|
248
|
+
* accumulator and its per-run `replantear` count into the `plan` model's
|
|
249
|
+
* `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
|
|
250
|
+
* pair has no v2 evidence. An empty input yields an empty map.
|
|
168
251
|
*/
|
|
169
252
|
export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
170
253
|
interface Acc {
|
|
@@ -172,11 +255,22 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
172
255
|
passes: number;
|
|
173
256
|
passRounds: number;
|
|
174
257
|
passRoundCount: number;
|
|
258
|
+
v2Samples: number;
|
|
259
|
+
corregirTotal: number;
|
|
260
|
+
replantearTotal: number;
|
|
175
261
|
}
|
|
176
262
|
const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
|
|
177
263
|
|
|
178
264
|
for (const record of records) {
|
|
179
265
|
const isPass = record.verdict === "pasa";
|
|
266
|
+
const isV2 = record.verdicts !== undefined;
|
|
267
|
+
const corregirCount = isV2
|
|
268
|
+
? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "corregir").length
|
|
269
|
+
: 0;
|
|
270
|
+
const replantearCount = isV2
|
|
271
|
+
? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "replantear").length
|
|
272
|
+
: 0;
|
|
273
|
+
|
|
180
274
|
for (const phase of RECORD_PHASES) {
|
|
181
275
|
const phaseRun = record.phases[phase];
|
|
182
276
|
const model = phaseRun?.model;
|
|
@@ -185,7 +279,19 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
185
279
|
const key = statKey(phase, model);
|
|
186
280
|
let bucket = acc.get(key);
|
|
187
281
|
if (bucket === undefined) {
|
|
188
|
-
bucket = {
|
|
282
|
+
bucket = {
|
|
283
|
+
phase,
|
|
284
|
+
model,
|
|
285
|
+
data: {
|
|
286
|
+
samples: 0,
|
|
287
|
+
passes: 0,
|
|
288
|
+
passRounds: 0,
|
|
289
|
+
passRoundCount: 0,
|
|
290
|
+
v2Samples: 0,
|
|
291
|
+
corregirTotal: 0,
|
|
292
|
+
replantearTotal: 0,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
189
295
|
acc.set(key, bucket);
|
|
190
296
|
}
|
|
191
297
|
bucket.data.samples += 1;
|
|
@@ -198,18 +304,29 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
198
304
|
bucket.data.passRoundCount += 1;
|
|
199
305
|
}
|
|
200
306
|
}
|
|
307
|
+
if (isV2) {
|
|
308
|
+
bucket.data.v2Samples += 1;
|
|
309
|
+
// `corregir` blames the build phase's model, `replantear` the plan
|
|
310
|
+
// phase's. The mapping is the pipeline contract, fixed and known.
|
|
311
|
+
if (phase === "build") bucket.data.corregirTotal += corregirCount;
|
|
312
|
+
if (phase === "plan") bucket.data.replantearTotal += replantearCount;
|
|
313
|
+
}
|
|
201
314
|
}
|
|
202
315
|
}
|
|
203
316
|
|
|
204
317
|
const stats = new Map<string, PhaseModelStat>();
|
|
205
318
|
for (const [key, bucket] of acc) {
|
|
206
|
-
const { samples, passes, passRounds, passRoundCount } =
|
|
319
|
+
const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
|
|
320
|
+
bucket.data;
|
|
207
321
|
stats.set(key, {
|
|
208
322
|
phase: bucket.phase,
|
|
209
323
|
model: bucket.model,
|
|
210
324
|
samples,
|
|
211
325
|
passRate: samples > 0 ? passes / samples : 0,
|
|
212
326
|
avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
|
|
327
|
+
v2Samples,
|
|
328
|
+
avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
|
|
329
|
+
avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
|
|
213
330
|
});
|
|
214
331
|
}
|
|
215
332
|
return stats;
|
|
@@ -292,6 +409,17 @@ export const HIGH_AVG_ROUNDS = 2.5;
|
|
|
292
409
|
/** Pass-rate at or over this value marks a phase as reliable — stay put. */
|
|
293
410
|
export const RELIABLE_PASS_RATE = 0.85;
|
|
294
411
|
|
|
412
|
+
/** Below this many `v:2` records a phase is left dormant — too little
|
|
413
|
+
* attributed evidence to act on (Story 7). Reuses v1's `MIN_SAMPLES` value. */
|
|
414
|
+
export const MIN_V2_SAMPLES = 5;
|
|
415
|
+
|
|
416
|
+
/** Mean `corregir`/run strictly above this value blames the `build` model. */
|
|
417
|
+
export const HIGH_AVG_CORREGIR = 1.0;
|
|
418
|
+
|
|
419
|
+
/** Mean `replantear`/run strictly above this value blames the `plan` model.
|
|
420
|
+
* Lower than `HIGH_AVG_CORREGIR` because a `replantear` is rarer and costlier. */
|
|
421
|
+
export const HIGH_AVG_REPLANTEAR = 0.5;
|
|
422
|
+
|
|
295
423
|
/**
|
|
296
424
|
* A proposed change of model for one SDD phase. `reason` is a human-readable
|
|
297
425
|
* string used verbatim in notifications.
|
|
@@ -307,22 +435,33 @@ export interface Adjustment {
|
|
|
307
435
|
reason: string;
|
|
308
436
|
}
|
|
309
437
|
|
|
438
|
+
/** The phases v2 autotune can attribute blame to and adjust. `explore` and
|
|
439
|
+
* `veredicto` are structurally excluded — no round verdict ever blames them. */
|
|
440
|
+
const ATTRIBUTABLE_PHASES = ["build", "plan"] as const;
|
|
441
|
+
|
|
310
442
|
/**
|
|
311
|
-
* Decide model adjustments from aggregated statistics.
|
|
443
|
+
* Decide model adjustments from aggregated statistics — v2 phase-attributed.
|
|
312
444
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* -
|
|
445
|
+
* Only the `build` and `plan` phases are considered: a `corregir` verdict
|
|
446
|
+
* re-runs build and a `replantear` verdict re-runs plan, so those are the only
|
|
447
|
+
* phases the verdict sequence can blame. `explore` and `veredicto` are
|
|
448
|
+
* structurally never iterated and so never adjusted (Story 6).
|
|
449
|
+
*
|
|
450
|
+
* For each attributable phase, looks up the `(phase, currentModel)` stat and:
|
|
451
|
+
* - skips an absent stat;
|
|
452
|
+
* - skips a phase with `v2Samples < MIN_V2_SAMPLES` — too little attributed
|
|
453
|
+
* evidence (the dormancy gate, Story 7);
|
|
454
|
+
* - reads the phase's blame measure (`build` → `avgCorregir` vs
|
|
455
|
+
* `HIGH_AVG_CORREGIR`, `plan` → `avgReplantear` vs `HIGH_AVG_REPLANTEAR`)
|
|
456
|
+
* and skips when it is `null` or not strictly above its threshold;
|
|
457
|
+
* - otherwise proposes one tier up via `stepUp`.
|
|
321
458
|
*
|
|
322
459
|
* Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
|
|
323
460
|
* model (so a phase already at `opus` or on an untierable model is left
|
|
324
461
|
* alone), `stepUp` never jumps more than one tier, and it never returns a
|
|
325
|
-
* model outside `knownModels`-or-the-fixed-representative set.
|
|
462
|
+
* model outside `knownModels`-or-the-fixed-representative set. v1's
|
|
463
|
+
* `LOW_PASS_RATE`/`HIGH_AVG_ROUNDS`/`RELIABLE_PASS_RATE` constants remain
|
|
464
|
+
* exported but no longer drive this function.
|
|
326
465
|
*/
|
|
327
466
|
export function decideAdjustments(
|
|
328
467
|
stats: Map<string, PhaseModelStat>,
|
|
@@ -331,29 +470,25 @@ export function decideAdjustments(
|
|
|
331
470
|
): Adjustment[] {
|
|
332
471
|
const adjustments: Adjustment[] = [];
|
|
333
472
|
|
|
334
|
-
for (const phase of
|
|
473
|
+
for (const phase of ATTRIBUTABLE_PHASES) {
|
|
335
474
|
const currentModel = currentModels[phase];
|
|
336
475
|
if (typeof currentModel !== "string" || currentModel === "") continue;
|
|
337
476
|
|
|
338
477
|
const stat = stats.get(statKey(phase, currentModel));
|
|
339
|
-
if (stat === undefined
|
|
478
|
+
if (stat === undefined) continue;
|
|
340
479
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const reliable =
|
|
344
|
-
stat.passRate >= RELIABLE_PASS_RATE &&
|
|
345
|
-
(stat.avgRounds === null || stat.avgRounds <= HIGH_AVG_ROUNDS);
|
|
480
|
+
// Dormancy gate: too few v2 records to attribute blame.
|
|
481
|
+
if (stat.v2Samples < MIN_V2_SAMPLES) continue;
|
|
346
482
|
|
|
347
|
-
|
|
348
|
-
|
|
483
|
+
const measure = phase === "build" ? stat.avgCorregir : stat.avgReplantear;
|
|
484
|
+
const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
|
|
485
|
+
if (measure === null || !(measure > threshold)) continue;
|
|
349
486
|
|
|
350
487
|
const to = stepUp(currentModel, knownModels);
|
|
351
488
|
if (to === null) continue; // already at top tier, or untierable
|
|
352
489
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
? `pass-rate ${stat.passRate.toFixed(2)} over ${stat.samples} runs`
|
|
356
|
-
: `avg ${(stat.avgRounds as number).toFixed(1)} rounds-to-pass over ${stat.samples} runs`;
|
|
490
|
+
const blame = phase === "build" ? "corregir" : "replantear";
|
|
491
|
+
const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
|
|
357
492
|
|
|
358
493
|
adjustments.push({ phase, from: currentModel, to, reason });
|
|
359
494
|
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// zero-pi — canonical spec store delta-merge engine, pure-logic module.
|
|
2
|
+
//
|
|
3
|
+
// zero keeps a project-wide canonical spec store under `.sdd/specs/`. Every
|
|
4
|
+
// `/forge` run produces a *delta* (`.sdd/<slug>/spec.md`) and, once the run
|
|
5
|
+
// reaches a `pasa` verdict, the delta is folded into the store. The merge
|
|
6
|
+
// *must* be deterministic and reproducible (the same delta against the same
|
|
7
|
+
// store always yields the same result) — so every parsing, guardrail, and
|
|
8
|
+
// merge decision lives here in plain, dependency-free TypeScript, exactly like
|
|
9
|
+
// `autotune.ts`. The pi wiring lives in `spec-merge-extension.ts`.
|
|
10
|
+
//
|
|
11
|
+
// This file has no pi imports, no filesystem access, and no side-effecting
|
|
12
|
+
// top-level code: parsing and merging are pure string work. It never throws on
|
|
13
|
+
// malformed input — a bad emission degrades to a surfaced `MergeError`, never
|
|
14
|
+
// a corrupted store.
|
|
15
|
+
|
|
16
|
+
/** One named requirement: stable name + raw body text (criteria included). */
|
|
17
|
+
export interface RequirementBlock {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Verbatim block body, trimmed; `""` allowed for a REMOVED entry. */
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A parsed delta — three buckets, any may be empty. */
|
|
24
|
+
export interface SpecDelta {
|
|
25
|
+
added: RequirementBlock[];
|
|
26
|
+
modified: RequirementBlock[];
|
|
27
|
+
removed: RequirementBlock[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A guardrail violation. `kind` drives the human message. */
|
|
31
|
+
export interface MergeError {
|
|
32
|
+
kind:
|
|
33
|
+
| "duplicate-in-delta" // same name twice across/within delta sections
|
|
34
|
+
| "added-name-collision" // ADDED name already in the store
|
|
35
|
+
| "modified-missing" // MODIFIED names a block absent from the store
|
|
36
|
+
| "removed-missing" // REMOVED names a block absent from the store
|
|
37
|
+
| "parse-error"; // malformed store or delta input
|
|
38
|
+
/** Offending requirement name (`""` for a `parse-error`). */
|
|
39
|
+
name: string;
|
|
40
|
+
/** Ready-to-surface explanation. */
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a successful merge changed — drives the sync report. */
|
|
45
|
+
export interface MergeSummary {
|
|
46
|
+
added: string[]; // names appended
|
|
47
|
+
modified: string[]; // names replaced
|
|
48
|
+
removed: string[]; // names deleted
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of a merge attempt: either the new store text, or the errors. */
|
|
52
|
+
export type MergeResult =
|
|
53
|
+
| { ok: true; store: string; summary: MergeSummary }
|
|
54
|
+
| { ok: false; errors: MergeError[] };
|
|
55
|
+
|
|
56
|
+
/** The pinned `# ` title line of `.sdd/specs/requirements.md`. A stable title
|
|
57
|
+
* keeps `renderStore` ↔ `parseStore` a clean round-trip. */
|
|
58
|
+
export const STORE_TITLE = "Canonical specs";
|
|
59
|
+
|
|
60
|
+
/** Matches a requirement-block header: `### REQ: <name>`. The name is
|
|
61
|
+
* everything after `REQ:`, captured for trimming by the caller. */
|
|
62
|
+
const REQ_HEADER = /^###\s+REQ:\s*(.*)$/;
|
|
63
|
+
|
|
64
|
+
/** Matches a delta section header: `## ADDED` / `## MODIFIED` / `## REMOVED`
|
|
65
|
+
* (case-insensitive on the keyword). */
|
|
66
|
+
const SECTION_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED)\s*$/i;
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Parsing
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Split a run of text lines into `### REQ:` blocks.
|
|
74
|
+
*
|
|
75
|
+
* Each block starts at a `### REQ:` header and runs up to (but not including)
|
|
76
|
+
* the next `### REQ:` header or the end of the input. The name is everything
|
|
77
|
+
* after `REQ:`, trimmed; the body is every following line up to the next
|
|
78
|
+
* header, joined and trimmed. Lines before the first header are ignored — they
|
|
79
|
+
* are a title or a section heading the caller already consumed. A header with
|
|
80
|
+
* an empty name still produces a block (with `name === ""`); the caller's
|
|
81
|
+
* guardrails decide whether that is acceptable.
|
|
82
|
+
*/
|
|
83
|
+
function parseBlocks(lines: readonly string[]): RequirementBlock[] {
|
|
84
|
+
const blocks: RequirementBlock[] = [];
|
|
85
|
+
let current: { name: string; body: string[] } | null = null;
|
|
86
|
+
|
|
87
|
+
const flush = (): void => {
|
|
88
|
+
if (current !== null) {
|
|
89
|
+
blocks.push({ name: current.name, body: current.body.join("\n").trim() });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const header = line.match(REQ_HEADER);
|
|
95
|
+
if (header !== null) {
|
|
96
|
+
flush();
|
|
97
|
+
current = { name: header[1].trim(), body: [] };
|
|
98
|
+
} else if (current !== null) {
|
|
99
|
+
current.body.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
flush();
|
|
103
|
+
|
|
104
|
+
return blocks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a canonical store file into ordered blocks.
|
|
109
|
+
*
|
|
110
|
+
* The store is a `# ` title line followed by a sequence of `### REQ:` blocks —
|
|
111
|
+
* there are no `## ADDED` etc. headings in the *store*. Any line before the
|
|
112
|
+
* first `### REQ:` header (the title, blank lines) is ignored. Never throws:
|
|
113
|
+
* malformed input simply yields whatever blocks could be recognized — the
|
|
114
|
+
* caller's `parse-error` guardrail decides whether the result is usable.
|
|
115
|
+
* Empty or whitespace-only text yields `[]`.
|
|
116
|
+
*/
|
|
117
|
+
export function parseStore(text: string): RequirementBlock[] {
|
|
118
|
+
if (typeof text !== "string") return [];
|
|
119
|
+
return parseBlocks(text.split(/\r?\n/));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a delta `spec.md` into a `SpecDelta`.
|
|
124
|
+
*
|
|
125
|
+
* Walks the file line by line, switching the active bucket on each
|
|
126
|
+
* `## ADDED` / `## MODIFIED` / `## REMOVED` header and collecting the
|
|
127
|
+
* `### REQ:` blocks that follow into that bucket. Lines outside any section
|
|
128
|
+
* (the `# ` title, blank lines) are ignored. An absent or empty section simply
|
|
129
|
+
* yields an empty bucket. Never throws — missing/non-string text yields an
|
|
130
|
+
* empty delta. A `### REQ:` block that appears before any section header is
|
|
131
|
+
* dropped (it belongs to no bucket); the caller treats a delta with zero
|
|
132
|
+
* blocks as "empty delta — nothing to sync", not an error.
|
|
133
|
+
*/
|
|
134
|
+
export function parseDelta(text: string): SpecDelta {
|
|
135
|
+
const delta: SpecDelta = { added: [], modified: [], removed: [] };
|
|
136
|
+
if (typeof text !== "string") return delta;
|
|
137
|
+
|
|
138
|
+
let section: keyof SpecDelta | null = null;
|
|
139
|
+
let buffer: string[] = [];
|
|
140
|
+
|
|
141
|
+
const flush = (): void => {
|
|
142
|
+
if (section !== null && buffer.length > 0) {
|
|
143
|
+
delta[section].push(...parseBlocks(buffer));
|
|
144
|
+
}
|
|
145
|
+
buffer = [];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const line of text.split(/\r?\n/)) {
|
|
149
|
+
const sectionHeader = line.match(SECTION_HEADER);
|
|
150
|
+
if (sectionHeader !== null) {
|
|
151
|
+
flush();
|
|
152
|
+
const keyword = sectionHeader[1].toUpperCase();
|
|
153
|
+
section =
|
|
154
|
+
keyword === "ADDED" ? "added" : keyword === "MODIFIED" ? "modified" : "removed";
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (section !== null) buffer.push(line);
|
|
158
|
+
}
|
|
159
|
+
flush();
|
|
160
|
+
|
|
161
|
+
return delta;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Total block count across the three delta buckets. */
|
|
165
|
+
function deltaBlockCount(delta: SpecDelta): number {
|
|
166
|
+
return delta.added.length + delta.modified.length + delta.removed.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Guardrails
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run every guardrail check against a parsed store + delta.
|
|
175
|
+
*
|
|
176
|
+
* Returns a `MergeError[]` — empty when the merge is safe to apply. Each error
|
|
177
|
+
* carries the offending `name` and a ready-to-surface `message`. The checks,
|
|
178
|
+
* in order:
|
|
179
|
+
*
|
|
180
|
+
* - `parse-error` — a block (store or delta) carries an empty name; the
|
|
181
|
+
* input could not be structurally understood.
|
|
182
|
+
* - `duplicate-in-delta` — the same name appears more than once across (or
|
|
183
|
+
* within) the ADDED/MODIFIED/REMOVED sections.
|
|
184
|
+
* - `added-name-collision` — an ADDED name already exists in the store (to
|
|
185
|
+
* *change* a block the run must use MODIFIED).
|
|
186
|
+
* - `modified-missing` — a MODIFIED block names a requirement absent from
|
|
187
|
+
* the store.
|
|
188
|
+
* - `removed-missing` — a REMOVED block names a requirement absent from the
|
|
189
|
+
* store.
|
|
190
|
+
*
|
|
191
|
+
* Pure, never throws. `mergeDelta` runs this before applying *any* change, so
|
|
192
|
+
* a non-empty result means the whole delta is rejected and the store is left
|
|
193
|
+
* untouched.
|
|
194
|
+
*/
|
|
195
|
+
export function checkGuardrails(
|
|
196
|
+
store: readonly RequirementBlock[],
|
|
197
|
+
delta: SpecDelta,
|
|
198
|
+
): MergeError[] {
|
|
199
|
+
const errors: MergeError[] = [];
|
|
200
|
+
|
|
201
|
+
// parse-error — any block (store or delta) with an empty name is malformed.
|
|
202
|
+
for (const block of store) {
|
|
203
|
+
if (block.name === "") {
|
|
204
|
+
errors.push({
|
|
205
|
+
kind: "parse-error",
|
|
206
|
+
name: "",
|
|
207
|
+
message:
|
|
208
|
+
"malformed store: a `### REQ:` block has an empty name — the store could not be parsed",
|
|
209
|
+
});
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
214
|
+
if (block.name === "") {
|
|
215
|
+
errors.push({
|
|
216
|
+
kind: "parse-error",
|
|
217
|
+
name: "",
|
|
218
|
+
message:
|
|
219
|
+
"malformed delta: a `### REQ:` block has an empty name — the delta could not be parsed",
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// duplicate-in-delta — the same name twice across all three delta sections.
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const flagged = new Set<string>();
|
|
228
|
+
for (const block of [...delta.added, ...delta.modified, ...delta.removed]) {
|
|
229
|
+
if (block.name === "") continue; // already reported as a parse-error
|
|
230
|
+
if (seen.has(block.name)) {
|
|
231
|
+
if (!flagged.has(block.name)) {
|
|
232
|
+
flagged.add(block.name);
|
|
233
|
+
errors.push({
|
|
234
|
+
kind: "duplicate-in-delta",
|
|
235
|
+
name: block.name,
|
|
236
|
+
message: `duplicate requirement "${block.name}": it appears more than once in the delta — each requirement may appear at most once across ADDED/MODIFIED/REMOVED`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
seen.add(block.name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const storeNames = new Set(store.map((b) => b.name));
|
|
245
|
+
|
|
246
|
+
// added-name-collision — an ADDED name already exists in the store.
|
|
247
|
+
for (const block of delta.added) {
|
|
248
|
+
if (block.name === "") continue;
|
|
249
|
+
if (storeNames.has(block.name)) {
|
|
250
|
+
errors.push({
|
|
251
|
+
kind: "added-name-collision",
|
|
252
|
+
name: block.name,
|
|
253
|
+
message: `ADDED requirement "${block.name}" already exists in the store — use MODIFIED to change an existing requirement`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// modified-missing — a MODIFIED name is absent from the store.
|
|
259
|
+
for (const block of delta.modified) {
|
|
260
|
+
if (block.name === "") continue;
|
|
261
|
+
if (!storeNames.has(block.name)) {
|
|
262
|
+
errors.push({
|
|
263
|
+
kind: "modified-missing",
|
|
264
|
+
name: block.name,
|
|
265
|
+
message: `MODIFIED requirement "${block.name}" is not in the store — only an existing requirement can be modified`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// removed-missing — a REMOVED name is absent from the store.
|
|
271
|
+
for (const block of delta.removed) {
|
|
272
|
+
if (block.name === "") continue;
|
|
273
|
+
if (!storeNames.has(block.name)) {
|
|
274
|
+
errors.push({
|
|
275
|
+
kind: "removed-missing",
|
|
276
|
+
name: block.name,
|
|
277
|
+
message: `REMOVED requirement "${block.name}" is not in the store — only an existing requirement can be removed`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return errors;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Render
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Render an ordered block list back to canonical store markdown.
|
|
291
|
+
*
|
|
292
|
+
* The output is a `# ` title line, a blank line, then every block as a
|
|
293
|
+
* `### REQ: <name>` header followed by its body. Blocks are separated by a
|
|
294
|
+
* blank line. Round-trips with `parseStore`: `parseStore(renderStore(blocks))`
|
|
295
|
+
* yields the same names and (trimmed) bodies. `title` defaults to the pinned
|
|
296
|
+
* `STORE_TITLE`. The result always ends with a single trailing newline.
|
|
297
|
+
*/
|
|
298
|
+
export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
299
|
+
const parts: string[] = [`# ${title}`];
|
|
300
|
+
for (const block of blocks) {
|
|
301
|
+
const body = block.body.trim();
|
|
302
|
+
parts.push(body === "" ? `### REQ: ${block.name}` : `### REQ: ${block.name}\n\n${body}`);
|
|
303
|
+
}
|
|
304
|
+
return `${parts.join("\n\n")}\n`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Merge entry point
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The whole pipeline: parse both inputs, run every guardrail, and — only when
|
|
313
|
+
* there are zero errors — apply the delta and render the new store.
|
|
314
|
+
*
|
|
315
|
+
* This is the single entry point the `/zero-sync` wiring calls. It is pure: no
|
|
316
|
+
* filesystem, no pi, never throws.
|
|
317
|
+
*
|
|
318
|
+
* On any guardrail error it returns `{ ok: false, errors }` and **no store
|
|
319
|
+
* text is produced** — a partially-applied store is structurally impossible,
|
|
320
|
+
* because the merge runs after *all* guardrails pass. On success it returns
|
|
321
|
+
* `{ ok: true, store, summary }` where the delta has been applied as:
|
|
322
|
+
*
|
|
323
|
+
* - ADDED → appended to the end of the store, in delta order;
|
|
324
|
+
* - MODIFIED → the matching store block's body is replaced in place;
|
|
325
|
+
* - REMOVED → the matching store block is deleted.
|
|
326
|
+
*
|
|
327
|
+
* An empty delta (zero blocks in every section) is *not* an error: it returns
|
|
328
|
+
* `ok: true` with the store unchanged and an empty `summary`, so the caller
|
|
329
|
+
* can report "empty delta — nothing to sync".
|
|
330
|
+
*/
|
|
331
|
+
export function mergeDelta(storeText: string, deltaText: string): MergeResult {
|
|
332
|
+
const store = parseStore(storeText);
|
|
333
|
+
const delta = parseDelta(deltaText);
|
|
334
|
+
|
|
335
|
+
const errors = checkGuardrails(store, delta);
|
|
336
|
+
if (errors.length > 0) {
|
|
337
|
+
return { ok: false, errors };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Apply the delta. Guardrails have proven every name resolves, so the
|
|
341
|
+
// mutations below cannot fail or leave the store half-applied.
|
|
342
|
+
const removedNames = new Set(delta.removed.map((b) => b.name));
|
|
343
|
+
const modifiedByName = new Map(delta.modified.map((b) => [b.name, b]));
|
|
344
|
+
|
|
345
|
+
const merged: RequirementBlock[] = [];
|
|
346
|
+
for (const block of store) {
|
|
347
|
+
if (removedNames.has(block.name)) continue; // REMOVED — drop it
|
|
348
|
+
const replacement = modifiedByName.get(block.name);
|
|
349
|
+
merged.push(replacement ?? block); // MODIFIED — replace; else keep
|
|
350
|
+
}
|
|
351
|
+
for (const block of delta.added) {
|
|
352
|
+
merged.push(block); // ADDED — append in delta order
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const summary: MergeSummary = {
|
|
356
|
+
added: delta.added.map((b) => b.name),
|
|
357
|
+
modified: delta.modified.map((b) => b.name),
|
|
358
|
+
removed: delta.removed.map((b) => b.name),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { ok: true, store: renderStore(merged), summary };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether a parsed delta carries no blocks at all.
|
|
366
|
+
*
|
|
367
|
+
* The `/zero-sync` command uses this to report "empty delta — nothing to sync"
|
|
368
|
+
* before touching the store: a zero-block delta is valid (not a `MergeError`),
|
|
369
|
+
* it simply has nothing to apply.
|
|
370
|
+
*/
|
|
371
|
+
export function isEmptyDelta(delta: SpecDelta): boolean {
|
|
372
|
+
return deltaBlockCount(delta) === 0;
|
|
373
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow, skill auto-learning, and an animated ZERO startup banner. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"extensions": [
|
|
23
23
|
"./extensions/startup-banner.ts",
|
|
24
24
|
"./extensions/zero-models.ts",
|
|
25
|
-
"./extensions/autotune-extension.ts"
|
|
25
|
+
"./extensions/autotune-extension.ts",
|
|
26
|
+
"./extensions/spec-merge-extension.ts"
|
|
26
27
|
]
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
"extensions/zero-models.ts",
|
|
33
34
|
"extensions/autotune.ts",
|
|
34
35
|
"extensions/autotune-extension.ts",
|
|
36
|
+
"extensions/spec-merge.ts",
|
|
37
|
+
"extensions/spec-merge-extension.ts",
|
|
35
38
|
"README.md",
|
|
36
39
|
"LICENSE"
|
|
37
40
|
],
|
package/prompts/orchestrator.md
CHANGED
|
@@ -173,7 +173,7 @@ memory" Cortex save above; do both.
|
|
|
173
173
|
The line is one `RunRecord` JSON object, serialized with no pretty-printing,
|
|
174
174
|
followed by a single newline. Build it from facts you already hold:
|
|
175
175
|
|
|
176
|
-
- `v`: the schema version — always the integer `
|
|
176
|
+
- `v`: the schema version — always the integer `2`.
|
|
177
177
|
- `ts`: the run-end timestamp, ISO 8601 (e.g. `2026-05-17T14:03:22.000Z`).
|
|
178
178
|
- `feature`: the SDD feature slug for this run.
|
|
179
179
|
- `phases`: an object with the four keys `explore`, `plan`, `build`,
|
|
@@ -183,11 +183,18 @@ followed by a single newline. Build it from facts you already hold:
|
|
|
183
183
|
if the iteration cap was hit without one. No other values.
|
|
184
184
|
- `rounds`: the number of build/veredicto rounds (`1` for a clean first-pass
|
|
185
185
|
run).
|
|
186
|
+
- `verdicts`: the ordered per-round verdict sequence — one entry per round, in
|
|
187
|
+
chronological order, accumulated as `veredicto` returns each round's verdict.
|
|
188
|
+
Every entry is one of `"corregir"`, `"replantear"`, or `"pasa"`;
|
|
189
|
+
`"cap-reached"` is a run-level terminal state and never appears inside this
|
|
190
|
+
array. `verdicts.length` must equal `rounds` (one verdict per round,
|
|
191
|
+
including the final cap-reaching round). A `pasa` run ends with exactly one
|
|
192
|
+
`"pasa"`, as the last entry; a `cap-reached` run contains no `"pasa"` at all.
|
|
186
193
|
|
|
187
194
|
Exact one-line shape to emit:
|
|
188
195
|
|
|
189
196
|
```json
|
|
190
|
-
{"v":
|
|
197
|
+
{"v":2,"ts":"2026-05-17T14:03:22.000Z","feature":"adaptive-model-profiles","phases":{"explore":{"model":"claude-haiku-4-5"},"plan":{"model":"claude-opus-4-7"},"build":{"model":"claude-sonnet-4-6"},"veredicto":{"model":"claude-opus-4-7"}},"verdict":"pasa","rounds":2,"verdicts":["corregir","pasa"]}
|
|
191
198
|
```
|
|
192
199
|
|
|
193
200
|
Rules:
|
|
@@ -199,3 +206,41 @@ Rules:
|
|
|
199
206
|
- **No record without a verdict.** If the run was aborted before `veredicto`
|
|
200
207
|
ever produced a verdict, write nothing — only a `pasa` or `cap-reached` run
|
|
201
208
|
is recorded.
|
|
209
|
+
|
|
210
|
+
## Spec sync & archive
|
|
211
|
+
|
|
212
|
+
The project keeps a **canonical spec store** at `.sdd/specs/requirements.md` —
|
|
213
|
+
the accepted requirements of every prior run. A `/forge` run's `plan` phase
|
|
214
|
+
emits a delta `spec.md` against that store; once the run reaches a `pasa`
|
|
215
|
+
verdict the delta is folded back into the store.
|
|
216
|
+
|
|
217
|
+
**After a `pasa` verdict — and only then.** Alongside the Cortex save and the
|
|
218
|
+
`zero-runs.jsonl` append, invoke the **`/zero-sync <slug>`** command, passing
|
|
219
|
+
the run's feature slug explicitly. `/zero-sync` is a real pi command — a
|
|
220
|
+
deterministic, unit-tested merge, not a prompt instruction — that reads
|
|
221
|
+
`.sdd/specs/requirements.md` and `.sdd/<slug>/spec.md`, folds the delta into the
|
|
222
|
+
store, writes the store atomically, and archives the change. You only call it;
|
|
223
|
+
you never edit the store yourself.
|
|
224
|
+
|
|
225
|
+
**Never sync on a non-`pasa` outcome.** Do **not** invoke `/zero-sync` for a
|
|
226
|
+
`corregir` or `replantear` verdict, or when the iteration cap was reached
|
|
227
|
+
without a `pasa` — the store is changed by a `pasa` run only, and no archive
|
|
228
|
+
entry is created otherwise. Likewise skip it for a **legacy resumed run** whose
|
|
229
|
+
`.sdd/<slug>/` has no `spec.md` (the older artifact shape) — that run has no
|
|
230
|
+
delta to fold, and `/zero-sync` will report it has nothing to sync.
|
|
231
|
+
|
|
232
|
+
**On a guardrail error, surface — do not claim a sync.** If `/zero-sync`
|
|
233
|
+
reports a guardrail failure (a duplicate name, an ADDED collision, a MODIFIED or
|
|
234
|
+
REMOVED of a missing block, or a malformed store/delta) it writes **nothing**.
|
|
235
|
+
Relay the failing requirement name(s) and reason to the user and state plainly
|
|
236
|
+
that the canonical store was **not** updated and is out of sync for a manual
|
|
237
|
+
fix. The `pasa` verdict still stands — the build shipped; only the store fold
|
|
238
|
+
was rejected. Never report the store as updated when `/zero-sync` did not
|
|
239
|
+
update it.
|
|
240
|
+
|
|
241
|
+
**On success, relay the report.** When `/zero-sync` succeeds it writes the new
|
|
242
|
+
store and creates an archive entry at `.sdd/archive/<YYYY-MM-DD>-<slug>/` — a
|
|
243
|
+
copy of the run's `proposal.md` and `spec.md` plus a `sync.md` report listing
|
|
244
|
+
every added, modified, and removed requirement. Include `/zero-sync`'s report in
|
|
245
|
+
the run's final summary, calling out the destructive effects (replacements,
|
|
246
|
+
deletions) explicitly.
|
package/prompts/phases/plan.md
CHANGED
|
@@ -6,12 +6,13 @@ You run the **plan** phase of a zero SDD pipeline.
|
|
|
6
6
|
|
|
7
7
|
**Locating artifacts.** If you are invoked with a feature slug, operate on
|
|
8
8
|
`.sdd/<slug>/`. With no slug and exactly one candidate run on disk, use it; with
|
|
9
|
-
no slug and an ambiguous target, ask which run before acting. You write
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
rather than failing. On a resumed run,
|
|
13
|
-
`design.md` you depend on — if one
|
|
14
|
-
mid-write), rebuild it instead of trusting
|
|
9
|
+
no slug and an ambiguous target, ask which run before acting. You write four
|
|
10
|
+
artifacts into that directory — `proposal.md`, `spec.md`, `design.md`, and
|
|
11
|
+
`tasks.md` (see *Artifacts*). If invoked standalone with the explore findings
|
|
12
|
+
absent, gather the context you need first rather than failing. On a resumed run,
|
|
13
|
+
sanity-check any `proposal.md`, `spec.md`, or `design.md` you depend on — if one
|
|
14
|
+
is obviously incomplete (truncated mid-write), rebuild it instead of trusting
|
|
15
|
+
it.
|
|
15
16
|
|
|
16
17
|
Using the explore findings, write the plan: the requirements (what must be
|
|
17
18
|
true), the design (how it will be built), and an ordered list of small,
|
|
@@ -19,7 +20,43 @@ independently verifiable tasks.
|
|
|
19
20
|
|
|
20
21
|
Do not write implementation code in this phase — only the plan. Keep each task
|
|
21
22
|
scoped tightly enough that the build phase can implement and check it on its
|
|
22
|
-
own.
|
|
23
|
+
own. `plan` stays a single phase — read the store, write all four artifacts in
|
|
24
|
+
this one pass.
|
|
25
|
+
|
|
26
|
+
## Reading the canonical store
|
|
27
|
+
|
|
28
|
+
The project keeps a **canonical spec store** at `.sdd/specs/requirements.md` —
|
|
29
|
+
the accumulated, accepted requirements of every prior run. Read it as the
|
|
30
|
+
baseline before writing this run's requirements.
|
|
31
|
+
|
|
32
|
+
- **Absent** (`.sdd/specs/` or the file does not exist) → treat it as an empty
|
|
33
|
+
store: every requirement this run proposes is new, so the whole `spec.md`
|
|
34
|
+
delta is `## ADDED`.
|
|
35
|
+
- **Present and well-formed** → it is a `# ` title followed by `### REQ: <name>`
|
|
36
|
+
blocks. Use the existing block names as the identities you may MODIFY or
|
|
37
|
+
REMOVE.
|
|
38
|
+
- **Unreadable or malformed** → surface the error and stop before producing a
|
|
39
|
+
delta. Do **not** overwrite the store and do **not** guess — a bad store is a
|
|
40
|
+
blocker for the human, not something `plan` repairs.
|
|
41
|
+
|
|
42
|
+
## Artifacts
|
|
43
|
+
|
|
44
|
+
Write all four into `.sdd/<slug>/`:
|
|
45
|
+
|
|
46
|
+
- **`proposal.md`** — the change intent: what this run adds or changes, its
|
|
47
|
+
scope, and the rationale. Prose, no requirement blocks.
|
|
48
|
+
- **`spec.md`** — the **delta** against the canonical store, never a full spec.
|
|
49
|
+
Up to three `H2` sections — `## ADDED`, `## MODIFIED`, `## REMOVED` — each
|
|
50
|
+
holding `### REQ: <stable-unique-name>` blocks (one named requirement: prose
|
|
51
|
+
followed by an `Acceptance criteria:` list). `## ADDED` carries brand-new
|
|
52
|
+
requirements; `## MODIFIED` carries the **complete** updated text of an
|
|
53
|
+
existing block (full new body, not a diff); `## REMOVED` needs only the
|
|
54
|
+
`### REQ:` name line. Any section may be empty or absent; a block name must be
|
|
55
|
+
unique within and across the delta's sections and not collide with an existing
|
|
56
|
+
store name unless it is the target of MODIFIED/REMOVED.
|
|
57
|
+
- **`design.md`** — how it is built (unchanged from before).
|
|
58
|
+
- **`tasks.md`** — the ordered task list, keeping its `## Review Workload`
|
|
59
|
+
section (see below).
|
|
23
60
|
|
|
24
61
|
Honour the prior-run lessons carried in the explore findings: when a past run
|
|
25
62
|
was sent back with `replantear`, do not repeat the plan mistake it recorded.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Route a natural-language request into the zero SDD pipeline when the user signals SDD intent
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# zero — Natural-language SDD routing
|
|
6
|
+
|
|
7
|
+
zero's spec-driven development pipeline is normally started with the explicit
|
|
8
|
+
`/forge <feature>` command. This skill adds one purely additive convenience:
|
|
9
|
+
when the user **describes work** and **explicitly signals** that they want it
|
|
10
|
+
run through the disciplined SDD pipeline, route the request into the `/forge`
|
|
11
|
+
workflow instead of handling it ad-hoc.
|
|
12
|
+
|
|
13
|
+
`/forge` stays the primary, deliberate entry point. This skill never replaces
|
|
14
|
+
it — it only catches the case where the user expressed SDD intent in plain
|
|
15
|
+
language rather than typing the command.
|
|
16
|
+
|
|
17
|
+
## When to route
|
|
18
|
+
|
|
19
|
+
Route to `/forge` only when **both** of these hold:
|
|
20
|
+
|
|
21
|
+
1. The message describes **non-trivial work** — a feature, a refactor, a
|
|
22
|
+
multi-step change worth a spec.
|
|
23
|
+
2. The message contains a **clear SDD intent signal** — an explicit phrase
|
|
24
|
+
asking for the pipeline. Recognised signals (Spanish or English), and close
|
|
25
|
+
equivalents:
|
|
26
|
+
- "hacelo con sdd" / "do it with sdd"
|
|
27
|
+
- "usá el pipeline" / "use the pipeline"
|
|
28
|
+
- "con sdd" / "with sdd"
|
|
29
|
+
- "andá con forge" / "run forge" / "go with forge"
|
|
30
|
+
- "spec-driven" / "spec driven development"
|
|
31
|
+
|
|
32
|
+
When both hold, invoke the `/forge` workflow with the user's described work as
|
|
33
|
+
the feature request. Pass that described work **verbatim** — do not rephrase,
|
|
34
|
+
summarize, translate, or interpret it. The existing `/forge` workflow does the
|
|
35
|
+
rest: an ordinary SDD run with all four phases (explore, plan, build,
|
|
36
|
+
veredicto), the round cap, and the veredicto verdict, exactly as an explicit
|
|
37
|
+
`/forge` invocation behaves.
|
|
38
|
+
|
|
39
|
+
## When NOT to route — be conservative
|
|
40
|
+
|
|
41
|
+
This skill must **not** hijack ordinary interaction. Do nothing (handle the
|
|
42
|
+
request normally) when:
|
|
43
|
+
|
|
44
|
+
- The message has **no clear SDD intent signal** — description of work alone is
|
|
45
|
+
never enough.
|
|
46
|
+
- The user asks a **question**, requests a **small or one-off fix**, or makes
|
|
47
|
+
any routine request without an SDD signal.
|
|
48
|
+
- The SDD intent is **ambiguous or uncertain** in any way.
|
|
49
|
+
|
|
50
|
+
When in doubt, do nothing. Default to normal handling and leave the user to
|
|
51
|
+
invoke `/forge` explicitly. A missed natural-language route is harmless — the
|
|
52
|
+
user can still type `/forge`. A wrong route forces routine work through the
|
|
53
|
+
heavy pipeline, which is not. Triggering only on a clear signal phrase is the
|
|
54
|
+
rule; `/forge` remains the explicit primary entry point.
|