@gonrocca/zero-pi 0.1.49 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/extensions/gh-runner.ts +4 -2
- package/extensions/git-runner.ts +44 -0
- package/extensions/pr-body.ts +29 -6
- package/extensions/sdd-config.ts +38 -0
- package/extensions/sdd-links.ts +16 -6
- package/extensions/spec-merge.ts +16 -3
- package/extensions/zero-archive-extension.ts +44 -0
- package/extensions/zero-branch-extension.ts +64 -0
- package/extensions/zero-git-validate-extension.ts +56 -0
- package/extensions/zero-pr-extension.ts +24 -4
- package/extensions/zero-validate-extension.ts +6 -4
- package/extensions/zero-validate.ts +32 -3
- package/package.json +9 -1
- package/prompts/orchestrator.md +15 -6
- package/prompts/phases/plan.md +32 -0
- package/prompts/phases/veredicto.md +15 -0
- package/skills/sdd-routing/SKILL.md +9 -0
package/README.md
CHANGED
|
@@ -67,8 +67,8 @@ into `/forge` for you.
|
|
|
67
67
|
| ------- | ------------ |
|
|
68
68
|
| **`/zero-models`** | Pick the model + provider for each SDD phase — a boxed-window picker, or set one directly. |
|
|
69
69
|
| **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
|
|
70
|
-
| **`/zero-sync`** | Folds each run's spec delta into a canonical, project-wide spec store. |
|
|
71
|
-
| **PR / Issues** | `/zero-pr
|
|
70
|
+
| **`/zero-sync` / `/zero-archive`** | Folds each run's spec delta into a canonical, project-wide spec store and archives approved runs. |
|
|
71
|
+
| **Git / PR / Issues** | `/zero-branch`, `/zero-git-validate`, `/zero-pr`, and `/zero-issue` keep branches and GitHub links audit-ready. |
|
|
72
72
|
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
73
73
|
| **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
|
|
74
74
|
| **Startup banner** | The violet ANSI-Shadow `ZERO` wordmark, drawn once at pi startup — `ZERO_HEADER=off` to disable. |
|
|
@@ -85,14 +85,40 @@ into `/forge` for you.
|
|
|
85
85
|
| `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
|
|
86
86
|
| `/zero-models [<phase>=[<provider>/]<model>]` | Show or set per-phase models — `autotune=auto\|ask\|off`. |
|
|
87
87
|
| `/zero-sync <slug>` | Fold a run's spec delta into the canonical spec store; a first all-`## ADDED` delta creates the store lazily. |
|
|
88
|
-
| `/zero-
|
|
88
|
+
| `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
|
|
89
|
+
| `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
|
|
89
90
|
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
90
|
-
| `/zero-
|
|
91
|
+
| `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
|
|
92
|
+
| `/zero-git-validate <slug>` | Check worktree, branch, remote, `gh auth`, and verdict gating before PR/archive. |
|
|
93
|
+
| `/zero-pr <slug>` | Create an audit-ready GitHub PR from a run that already has verdict `pasa`. |
|
|
91
94
|
| `/zero-issue <slug>` | Create or reuse a GitHub issue for a run and persist the link. |
|
|
92
95
|
| `/zero-diff <slug>` | Preview the logical spec-store merge without writing files. |
|
|
93
96
|
| `/zero-resume` | Write the session handoff note now. |
|
|
94
97
|
|
|
95
|
-
### PR / Issues
|
|
98
|
+
### Git / PR / Issues
|
|
99
|
+
|
|
100
|
+
Recommended flow: `/zero-branch <slug>` → `/zero-issue <slug>` → `/forge <slug>` → `/zero-git-validate <slug> --for=pr` → `/zero-pr <slug>` → `/zero-archive <slug>`.
|
|
101
|
+
|
|
102
|
+
`links.json` is forward-compatible and may contain:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"issueNumber": 12,
|
|
107
|
+
"issueUrl": "https://github.com/org/repo/issues/12",
|
|
108
|
+
"branch": "sdd/my-feature",
|
|
109
|
+
"baseBranch": "main",
|
|
110
|
+
"prNumber": 34,
|
|
111
|
+
"prUrl": "https://github.com/org/repo/pull/34",
|
|
112
|
+
"archivePath": ".sdd/archive/2026-05-24-my-feature"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`/zero-branch <slug>` uses `.sdd/config.json` when present:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{ "git": { "branchPrefix": "sdd/", "numbering": false, "autoCommit": false, "commitStyle": "conventional", "baseBranch": "main" } }
|
|
120
|
+
```
|
|
121
|
+
|
|
96
122
|
|
|
97
123
|
`/zero-pr <slug>` builds a PR title/body from `.sdd/<slug>/proposal.md`,
|
|
98
124
|
`spec.md`, `design.md`, and `tasks.md`, then saves the returned `prNumber` and
|
package/extensions/gh-runner.ts
CHANGED
|
@@ -42,7 +42,7 @@ function parseVersion(stdout: string | undefined): string | undefined {
|
|
|
42
42
|
function parseCreatedUrl(stdout: string | undefined, kind: "pull" | "issues"): { number?: number; url?: string } {
|
|
43
43
|
const lines = (stdout ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
44
44
|
const url = [...lines].reverse().find((l) => /^https:\/\/github\.com\/.+/.test(l));
|
|
45
|
-
const number = url ? Number(new RegExp(`/${kind}/(\\d+)(?:$|[?#])`).exec(url)?.[1]) : undefined;
|
|
45
|
+
const number = url ? Number((new RegExp(`/${kind}/(\\d+)(?:$|[?#])`).exec(url) ?? /\/(?:pull|pulls|issues)\/(\d+)(?:$|[?#])/.exec(url))?.[1]) : undefined;
|
|
46
46
|
return { url, number: Number.isFinite(number) ? number : undefined };
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -58,8 +58,10 @@ export function createGhRunner({ spawn }: { spawn: SpawnLike }) {
|
|
|
58
58
|
if (!parsed.ok) return parsed as GhResult<string[]>;
|
|
59
59
|
return { ok: true, data: (parsed.data ?? []).map((l) => l.name).filter((n): n is string => typeof n === "string") };
|
|
60
60
|
},
|
|
61
|
-
async createPr(input: { title: string; bodyFile: string; labels?: string[]; label?: string }): Promise<GhResult<{ number?: number; url?: string }>> {
|
|
61
|
+
async createPr(input: { title: string; bodyFile: string; labels?: string[]; label?: string; base?: string; head?: string }): Promise<GhResult<{ number?: number; url?: string }>> {
|
|
62
62
|
const args = ["pr", "create", "--title", input.title, "--body-file", input.bodyFile];
|
|
63
|
+
if (input.base) args.push("--base", input.base);
|
|
64
|
+
if (input.head) args.push("--head", input.head);
|
|
63
65
|
for (const label of input.labels ?? (input.label ? [input.label] : [])) args.push("--label", label);
|
|
64
66
|
const r = await run(spawn, args);
|
|
65
67
|
return r.ok ? { ok: true, data: parseCreatedUrl(r.data, "pull"), stderr: r.stderr, exitCode: r.exitCode } : { ok: false, stderr: r.stderr, exitCode: r.exitCode };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface GitResult { ok: boolean; stdout: string; stderr: string; code: number }
|
|
4
|
+
export type SpawnLike = typeof spawn;
|
|
5
|
+
|
|
6
|
+
export interface GitRunner {
|
|
7
|
+
run(args: string[]): Promise<GitResult>;
|
|
8
|
+
currentBranch(): Promise<string>;
|
|
9
|
+
isDirty(): Promise<boolean>;
|
|
10
|
+
branchExists(branch: string, remote?: boolean): Promise<boolean>;
|
|
11
|
+
createBranch(branch: string, base?: string): Promise<GitResult>;
|
|
12
|
+
switchBranch(branch: string): Promise<GitResult>;
|
|
13
|
+
revParse(ref: string): Promise<GitResult>;
|
|
14
|
+
hasRemote(name?: string): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createGitRunner(spawnImpl: SpawnLike = spawn): GitRunner {
|
|
18
|
+
const run = (args: string[]): Promise<GitResult> => new Promise((resolve) => {
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
let child: ReturnType<SpawnLike>;
|
|
22
|
+
try {
|
|
23
|
+
child = spawnImpl("git", args, { shell: false }) as ReturnType<SpawnLike>;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
resolve({ ok: false, stdout: "", stderr: err instanceof Error ? err.message : String(err), code: -1 });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
child.stdout?.on("data", (d: Buffer | string) => { stdout += String(d); });
|
|
29
|
+
child.stderr?.on("data", (d: Buffer | string) => { stderr += String(d); });
|
|
30
|
+
child.on("error", (err: Error) => resolve({ ok: false, stdout, stderr: err.message, code: -1 }));
|
|
31
|
+
child.on("close", (code: number | null) => resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? -1 }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
run,
|
|
36
|
+
async currentBranch() { return (await run(["branch", "--show-current"])).stdout; },
|
|
37
|
+
async isDirty() { return (await run(["status", "--porcelain"])).stdout.trim() !== ""; },
|
|
38
|
+
async branchExists(branch: string, remote = false) { return (await run(remote ? ["ls-remote", "--exit-code", "--heads", "origin", branch] : ["rev-parse", "--verify", branch])).ok; },
|
|
39
|
+
createBranch(branch: string, base?: string) { return run(base ? ["switch", "-c", branch, base] : ["switch", "-c", branch]); },
|
|
40
|
+
switchBranch(branch: string) { return run(["switch", branch]); },
|
|
41
|
+
revParse(ref: string) { return run(["rev-parse", "--verify", ref]); },
|
|
42
|
+
async hasRemote(name = "origin") { return (await run(["remote", "get-url", name])).ok; },
|
|
43
|
+
};
|
|
44
|
+
}
|
package/extensions/pr-body.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export interface ArtifactTexts {
|
|
2
2
|
proposal?: string;
|
|
3
3
|
spec?: string;
|
|
4
|
+
specs?: Record<string, string>;
|
|
4
5
|
design?: string;
|
|
5
6
|
tasks?: string;
|
|
6
7
|
proposalMd?: string;
|
|
@@ -10,6 +11,8 @@ export interface ArtifactTexts {
|
|
|
10
11
|
linkedIssueNumber?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export interface PrBodyInput { slug?: string; links?: { issueNumber?: number; branch?: string; baseBranch?: string; prUrl?: string }; artifacts: ArtifactTexts }
|
|
15
|
+
|
|
13
16
|
export interface BuiltBody { title: string; body: string }
|
|
14
17
|
|
|
15
18
|
function normalize(text: string | undefined): string {
|
|
@@ -54,18 +57,19 @@ export function extractSectionByHeading(markdown: string | undefined, heading: s
|
|
|
54
57
|
export function extractTasksTable(tasks: string | undefined): string {
|
|
55
58
|
const text = normalize(tasks);
|
|
56
59
|
if (text === "") return "";
|
|
57
|
-
const taskMatches = [...text.matchAll(
|
|
60
|
+
const taskMatches = [...text.matchAll(/^(?:##\s+\[[ xX]\]\s+|###\s+)(T\d+[^\n]*)/gm)];
|
|
58
61
|
if (taskMatches.length === 0) return "";
|
|
59
|
-
const rows = ["| Task | Files |", "| --- | --- |"];
|
|
62
|
+
const rows = ["| Task | Files | Evidence |", "| --- | --- | --- |"];
|
|
60
63
|
for (const match of taskMatches) {
|
|
61
64
|
const title = match[1].trim();
|
|
62
65
|
const start = match.index ?? 0;
|
|
63
|
-
const next = text.slice(start + match[0].length).search(
|
|
66
|
+
const next = text.slice(start + match[0].length).search(/^#{2,3}\s+/m);
|
|
64
67
|
const block = next === -1 ? text.slice(start) : text.slice(start, start + match[0].length + next);
|
|
65
68
|
if (!/^\s*-\s+files:\s*$/m.test(block)) continue;
|
|
66
69
|
const files = [...block.matchAll(/^\s+-\s+`([^`]+)`/gm)].map((m) => m[1]);
|
|
70
|
+
const evidence = (/^\s*-\s+evidence:\s*(.+)$/m.exec(block)?.[1] ?? "").trim();
|
|
67
71
|
if (files.length === 0) continue;
|
|
68
|
-
rows.push(`| ${title} | ${files.join("<br>")} |`);
|
|
72
|
+
rows.push(`| ${title} | ${files.join("<br>")} | ${evidence || "—"} |`);
|
|
69
73
|
}
|
|
70
74
|
return rows.length > 2 ? rows.join("\n") : "";
|
|
71
75
|
}
|
|
@@ -99,22 +103,41 @@ function issueSections(input: ArtifactTexts): string[] {
|
|
|
99
103
|
return sections;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
export function buildPrBody(
|
|
106
|
+
export function buildPrBody(inputOrWrapped: ArtifactTexts | PrBodyInput, linksArg: { issueNumber?: number; branch?: string; baseBranch?: string } = {}): BuiltBody {
|
|
107
|
+
const wrapped = "artifacts" in inputOrWrapped ? inputOrWrapped as PrBodyInput : null;
|
|
108
|
+
const input = wrapped?.artifacts ?? inputOrWrapped as ArtifactTexts;
|
|
109
|
+
const links = { ...linksArg, ...(wrapped?.links ?? {}) };
|
|
103
110
|
const issueNumber = input.linkedIssueNumber ?? links.issueNumber;
|
|
104
111
|
const linkedIssue = issueNumber ? `Closes #${issueNumber}` : "Closes #\n<!-- Reemplazá # por el número de issue si existe. -->";
|
|
112
|
+
const specText = input.specs ? Object.entries(input.specs).map(([domain, spec]) => `### ${domain}\n\n${spec}`).join("\n\n") : specOf(input);
|
|
105
113
|
const body = [
|
|
106
114
|
"## 🔗 Linked Issue",
|
|
107
115
|
linkedIssue,
|
|
108
116
|
"",
|
|
117
|
+
"## SDD artifacts",
|
|
118
|
+
`- Slug: ${wrapped?.slug ?? "n/a"}`,
|
|
119
|
+
`- Branch: ${links.branch ?? "n/a"}`,
|
|
120
|
+
`- Base branch: ${links.baseBranch ?? "n/a"}`,
|
|
121
|
+
"- Artifacts: proposal.md, spec.md/specs/*/spec.md, design.md, tasks.md, veredicto.md",
|
|
122
|
+
"",
|
|
123
|
+
"## Requirements",
|
|
124
|
+
placeholder(extractAcceptanceCriteria(specText)),
|
|
125
|
+
"",
|
|
109
126
|
"## 📝 Summary",
|
|
110
127
|
placeholder(extractFirstParagraph(proposalOf(input))),
|
|
111
128
|
"",
|
|
112
129
|
"## 📂 Changes",
|
|
113
130
|
placeholder(extractTasksTable(tasksOf(input))),
|
|
114
131
|
"",
|
|
115
|
-
"## 🧪 Test
|
|
132
|
+
"## 🧪 Test evidence",
|
|
116
133
|
placeholder(input.verdictReasoning ?? ""),
|
|
117
134
|
"",
|
|
135
|
+
"## Risks",
|
|
136
|
+
placeholder(extractSectionByHeading(proposalOf(input), "Riesgos") || extractSectionByHeading(input.design, "Riesgos")),
|
|
137
|
+
"",
|
|
138
|
+
"## Verdict",
|
|
139
|
+
placeholder(input.verdictReasoning ?? "Sin veredicto registrado."),
|
|
140
|
+
"",
|
|
118
141
|
"## ✅ Checklist",
|
|
119
142
|
"- [ ] Revisé el diff local",
|
|
120
143
|
"- [ ] Corrí los tests relevantes",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface SddConfig {
|
|
5
|
+
git: {
|
|
6
|
+
branchPrefix: string;
|
|
7
|
+
numbering: boolean;
|
|
8
|
+
autoCommit: boolean;
|
|
9
|
+
commitStyle: "conventional" | "plain";
|
|
10
|
+
baseBranch: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SDD_CONFIG: SddConfig = {
|
|
15
|
+
git: { branchPrefix: "sdd/", numbering: false, autoCommit: false, commitStyle: "conventional", baseBranch: "main" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadSddConfig(root = process.cwd()): SddConfig {
|
|
23
|
+
const path = join(root, ".sdd", "config.json");
|
|
24
|
+
if (!existsSync(path)) return structuredClone(DEFAULT_SDD_CONFIG);
|
|
25
|
+
let parsed: Record<string, unknown>;
|
|
26
|
+
try { parsed = asObject(JSON.parse(readFileSync(path, "utf8"))); }
|
|
27
|
+
catch (err) { throw new Error(`invalid .sdd/config.json: ${err instanceof Error ? err.message : String(err)}`); }
|
|
28
|
+
const git = asObject(parsed.git);
|
|
29
|
+
return {
|
|
30
|
+
git: {
|
|
31
|
+
branchPrefix: typeof git.branchPrefix === "string" ? git.branchPrefix : DEFAULT_SDD_CONFIG.git.branchPrefix,
|
|
32
|
+
numbering: typeof git.numbering === "boolean" ? git.numbering : DEFAULT_SDD_CONFIG.git.numbering,
|
|
33
|
+
autoCommit: typeof git.autoCommit === "boolean" ? git.autoCommit : DEFAULT_SDD_CONFIG.git.autoCommit,
|
|
34
|
+
commitStyle: git.commitStyle === "plain" ? "plain" : DEFAULT_SDD_CONFIG.git.commitStyle,
|
|
35
|
+
baseBranch: typeof git.baseBranch === "string" ? git.baseBranch : DEFAULT_SDD_CONFIG.git.baseBranch,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/extensions/sdd-links.ts
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
export interface
|
|
4
|
+
export interface SddLinks {
|
|
5
5
|
prNumber?: number;
|
|
6
6
|
prUrl?: string;
|
|
7
7
|
issueNumber?: number;
|
|
8
8
|
issueUrl?: string;
|
|
9
|
+
branch?: string;
|
|
10
|
+
baseBranch?: string;
|
|
11
|
+
archivePath?: string;
|
|
12
|
+
createdAt?: string;
|
|
9
13
|
[key: string]: unknown;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export type LinksRecord = SddLinks;
|
|
17
|
+
|
|
12
18
|
function linksPath(sddDir: string, slug: string): string {
|
|
13
19
|
return join(sddDir, slug, "links.json");
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
function validRecord(value: unknown):
|
|
17
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as
|
|
22
|
+
function validRecord(value: unknown): SddLinks {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as SddLinks : {};
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
export function readLinks(sddDir: string, slug: string):
|
|
26
|
+
export function readLinks(sddDir: string, slug: string): SddLinks {
|
|
21
27
|
const path = linksPath(sddDir, slug);
|
|
22
28
|
if (!existsSync(path)) return {};
|
|
23
29
|
try {
|
|
@@ -27,9 +33,13 @@ export function readLinks(sddDir: string, slug: string): LinksRecord {
|
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
export function
|
|
36
|
+
export function mergeLinks(current: SddLinks, partial: SddLinks): SddLinks {
|
|
37
|
+
return { ...validRecord(current), ...validRecord(partial) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeLinks(sddDir: string, slug: string, partial: SddLinks): SddLinks {
|
|
31
41
|
const path = linksPath(sddDir, slug);
|
|
32
|
-
const next =
|
|
42
|
+
const next = mergeLinks(readLinks(sddDir, slug), partial);
|
|
33
43
|
mkdirSync(dirname(path), { recursive: true });
|
|
34
44
|
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
35
45
|
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
package/extensions/spec-merge.ts
CHANGED
|
@@ -97,6 +97,15 @@ export function parseStore(text: string): RequirementBlock[] {
|
|
|
97
97
|
return parseBlocks(text.split(/\r?\n/));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export function loadCanonical(text: string | undefined | null): { blocks: RequirementBlock[]; errors: MergeError[] } {
|
|
101
|
+
const source = text ?? "";
|
|
102
|
+
const blocks = parseStore(source);
|
|
103
|
+
const errors = blocks.some((b) => b.name === "")
|
|
104
|
+
? [{ kind: "parse-error" as const, name: "", message: "malformed canonical store: empty REQ name" }]
|
|
105
|
+
: [];
|
|
106
|
+
return { blocks, errors };
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
export function parseDelta(text: string): SpecDelta {
|
|
101
110
|
const delta: SpecDelta = { added: [], modified: [], removed: [], renamed: [] };
|
|
102
111
|
if (typeof text !== "string") return delta;
|
|
@@ -220,6 +229,10 @@ export function checkGuardrails(
|
|
|
220
229
|
return errors;
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
export function serializeCanonical(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
233
|
+
return renderStore([...blocks].sort((a, b) => a.name.localeCompare(b.name)), title);
|
|
234
|
+
}
|
|
235
|
+
|
|
223
236
|
export function renderStore(blocks: readonly RequirementBlock[], title: string = STORE_TITLE): string {
|
|
224
237
|
const parts: string[] = [`# ${title}`];
|
|
225
238
|
for (const block of blocks) {
|
|
@@ -229,9 +242,9 @@ export function renderStore(blocks: readonly RequirementBlock[], title: string =
|
|
|
229
242
|
return `${parts.join("\n\n")}\n`;
|
|
230
243
|
}
|
|
231
244
|
|
|
232
|
-
export function mergeDelta(
|
|
233
|
-
const store = parseStore(
|
|
234
|
-
const delta = parseDelta(
|
|
245
|
+
export function mergeDelta(storeTextOrBlocks: string | readonly RequirementBlock[], deltaTextOrDelta: string | SpecDelta): MergeResult {
|
|
246
|
+
const store = typeof storeTextOrBlocks === "string" ? parseStore(storeTextOrBlocks) : [...storeTextOrBlocks];
|
|
247
|
+
const delta = typeof deltaTextOrDelta === "string" ? parseDelta(deltaTextOrDelta) : deltaTextOrDelta;
|
|
235
248
|
const errors = checkGuardrails(store, delta);
|
|
236
249
|
if (errors.length > 0) return { ok: false, errors };
|
|
237
250
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { readRunRecords } from "./autotune.ts";
|
|
7
|
+
import { createGitRunner, type GitRunner, type SpawnLike } from "./git-runner.ts";
|
|
8
|
+
import { mergeDelta } from "./spec-merge.ts";
|
|
9
|
+
import { writeLinks } from "./sdd-links.ts";
|
|
10
|
+
import { validateSpecInputs, validateTasksFile } from "./zero-validate.ts";
|
|
11
|
+
|
|
12
|
+
const SDD_DIR = ".sdd";
|
|
13
|
+
type NotifyType = "info" | "warning" | "error";
|
|
14
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
15
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
16
|
+
|
|
17
|
+
function parse(args: string): { slug: string; dryRun: boolean; json: boolean; allowDirty: boolean } { const out = { slug: "", dryRun: false, json: false, allowDirty: false }; for (const p of args.trim().split(/\s+/).filter(Boolean)) { if (p === "--dry-run") out.dryRun = true; else if (p === "--json") out.json = true; else if (p === "--allow-dirty") out.allowDirty = true; else if (!out.slug) out.slug = p; } return out; }
|
|
18
|
+
function notify(ctx: PiCommandContext, message: string, type?: NotifyType) { try { ctx.ui.notify(message, type); } catch {} }
|
|
19
|
+
function latestVerdict(slug: string): string | null { const path = process.env.ZERO_RUNS_PATH || join(homedir(), ".pi", "zero-runs.jsonl"); let latest: { ts: string; verdict: string } | null = null; for (const r of readRunRecords(path)) if (r.feature === slug && (!latest || r.ts > latest.ts)) latest = { ts: r.ts, verdict: r.verdict }; return latest?.verdict ?? null; }
|
|
20
|
+
function today(): string { return new Date().toISOString().slice(0, 10); }
|
|
21
|
+
function read(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
|
|
22
|
+
function atomicWrite(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tmp, content, "utf8"); renameSync(tmp, path); }
|
|
23
|
+
function specPairs(runDir: string): Array<{ domain: string; deltaPath: string; canonicalPath: string }> { const out: Array<{ domain: string; deltaPath: string; canonicalPath: string }> = []; if (existsSync(join(runDir, "spec.md"))) out.push({ domain: "default", deltaPath: join(runDir, "spec.md"), canonicalPath: join(SDD_DIR, "specs", "requirements.md") }); try { const base = join(runDir, "specs"); for (const e of readdirSync(base, { withFileTypes: true })) if (e.isDirectory() && existsSync(join(base, e.name, "spec.md"))) out.push({ domain: e.name, deltaPath: join(base, e.name, "spec.md"), canonicalPath: join(SDD_DIR, "specs", e.name, "requirements.md") }); } catch {} return out; }
|
|
24
|
+
|
|
25
|
+
export async function runZeroArchive(args: string, ctx: PiCommandContext, git: GitRunner = createGitRunner(spawn as unknown as SpawnLike)): Promise<void> {
|
|
26
|
+
const opts = parse(args);
|
|
27
|
+
if (!opts.slug) { notify(ctx, "zero-archive: usage /zero-archive <slug> [--dry-run] [--json] [--allow-dirty]", "warning"); return; }
|
|
28
|
+
const runDir = join(SDD_DIR, opts.slug);
|
|
29
|
+
const emit = (payload: unknown, text: string, type: NotifyType = "info") => notify(ctx, opts.json ? JSON.stringify(payload) : text, type);
|
|
30
|
+
if (!existsSync(runDir)) { emit({ ok: false, reason: "missing-run" }, `zero-archive: no existe ${runDir}`, "error"); return; }
|
|
31
|
+
const verdict = latestVerdict(opts.slug); if (verdict !== "pasa") { emit({ ok: false, reason: "verdict", verdict }, `zero-archive: ${opts.slug} no tiene veredicto pasa (último: ${verdict ?? "—"})`, "error"); return; }
|
|
32
|
+
if (!opts.allowDirty && await git.isDirty()) { emit({ ok: false, reason: "dirty-worktree" }, "zero-archive: worktree sucio; usá --allow-dirty si corresponde", "error"); return; }
|
|
33
|
+
const defects = [...validateSpecInputs(runDir), ...validateTasksFile(read(join(runDir, "tasks.md")))]; if (defects.length) { emit({ ok: false, reason: "validate", defects }, `zero-archive: validación falló\n${defects.map((d) => ` - [${d.kind}] ${d.message}`).join("\n")}`, "error"); return; }
|
|
34
|
+
const pairs = specPairs(runDir); if (!pairs.length) { emit({ ok: false, reason: "missing-spec" }, "zero-archive: no hay spec.md para archivar", "error"); return; }
|
|
35
|
+
const writes: Array<{ path: string; next: string; before: string | null }> = [];
|
|
36
|
+
for (const pair of pairs) { const before = existsSync(pair.canonicalPath) ? read(pair.canonicalPath) : ""; const merged = mergeDelta(before, read(pair.deltaPath)); if (!merged.ok) { emit({ ok: false, reason: "merge", errors: merged.errors }, `zero-archive: merge falló en ${pair.domain}: ${merged.errors.map((e) => e.message).join("; ")}`, "error"); return; } writes.push({ path: pair.canonicalPath, next: merged.store, before: existsSync(pair.canonicalPath) ? before : null }); }
|
|
37
|
+
const archivePath = join(SDD_DIR, "archive", `${today()}-${opts.slug}`);
|
|
38
|
+
if (opts.dryRun) { emit({ ok: true, dryRun: true, archivePath, writes: writes.map((w) => w.path) }, `zero-archive: dry-run archivaría ${opts.slug} en ${archivePath}`); return; }
|
|
39
|
+
const written: typeof writes = [];
|
|
40
|
+
try { for (const w of writes) { atomicWrite(w.path, w.next); written.push(w); } writeLinks(SDD_DIR, opts.slug, { archivePath }); mkdirSync(dirname(archivePath), { recursive: true }); renameSync(runDir, archivePath); emit({ ok: true, archivePath, writes: writes.map((w) => w.path) }, `zero-archive: archivado en ${archivePath}`); }
|
|
41
|
+
catch (err) { for (const w of written.reverse()) { if (w.before === null) rmSync(w.path, { force: true }); else atomicWrite(w.path, w.before); } emit({ ok: false, reason: "rollback", error: err instanceof Error ? err.message : String(err) }, `zero-archive: falló y revertí cambios canónicos: ${err instanceof Error ? err.message : String(err)}`, "error"); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function register(pi?: PiExtensionAPI): void { if (!pi || typeof pi.registerCommand !== "function") return; pi.registerCommand("zero-archive", { description: "Mergea el delta aprobado a .sdd/specs y mueve el run a .sdd/archive", handler: (args, ctx) => runZeroArchive(args, ctx).catch((err) => notify(ctx, `zero-archive: ${err instanceof Error ? err.message : String(err)}`, "error")) }); }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { createGitRunner, type GitRunner, type SpawnLike } from "./git-runner.ts";
|
|
4
|
+
import { loadSddConfig } from "./sdd-config.ts";
|
|
5
|
+
import { writeLinks } from "./sdd-links.ts";
|
|
6
|
+
|
|
7
|
+
const SDD_DIR = ".sdd";
|
|
8
|
+
type NotifyType = "info" | "warning" | "error";
|
|
9
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
10
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
11
|
+
|
|
12
|
+
export interface BranchOptions { slug: string; dryRun: boolean; json: boolean; allowDirty: boolean; base?: string }
|
|
13
|
+
|
|
14
|
+
export function sanitizeSlug(slug: string): string {
|
|
15
|
+
return slug.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-") || "feature";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseBranchArgs(args: string): BranchOptions {
|
|
19
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
20
|
+
const out: BranchOptions = { slug: "", dryRun: false, json: false, allowDirty: false };
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
if (p === "--dry-run") out.dryRun = true;
|
|
23
|
+
else if (p === "--json") out.json = true;
|
|
24
|
+
else if (p === "--allow-dirty") out.allowDirty = true;
|
|
25
|
+
else if (p.startsWith("--base=")) out.base = p.slice(7);
|
|
26
|
+
else if (!out.slug) out.slug = p;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function notify(ctx: PiCommandContext, message: string, type?: NotifyType) { try { ctx.ui.notify(message, type); } catch {} }
|
|
32
|
+
function emit(ctx: PiCommandContext, opts: BranchOptions, payload: unknown, text: string, type: NotifyType = "info") { notify(ctx, opts.json ? JSON.stringify(payload) : text, type); }
|
|
33
|
+
|
|
34
|
+
export async function runZeroBranch(args: string, ctx: PiCommandContext, runner?: GitRunner): Promise<void> {
|
|
35
|
+
const opts = parseBranchArgs(args);
|
|
36
|
+
if (!opts.slug) { notify(ctx, "zero-branch: usage /zero-branch <slug> [--dry-run] [--json] [--allow-dirty] [--base=<branch>]", "warning"); return; }
|
|
37
|
+
const config = loadSddConfig(process.cwd());
|
|
38
|
+
const branch = `${config.git.branchPrefix}${sanitizeSlug(opts.slug)}`;
|
|
39
|
+
const base = opts.base ?? config.git.baseBranch;
|
|
40
|
+
const git = runner ?? createGitRunner(spawn as unknown as SpawnLike);
|
|
41
|
+
if (!opts.allowDirty && await git.isDirty()) { emit(ctx, opts, { ok: false, branch, reason: "dirty-worktree" }, `zero-branch: worktree sucio; usá --allow-dirty si querés continuar`, "error"); return; }
|
|
42
|
+
if (opts.dryRun) { emit(ctx, opts, { ok: true, dryRun: true, branch, baseBranch: base }, `zero-branch: dry-run crearía/cambiaría a ${branch} desde ${base}`); return; }
|
|
43
|
+
|
|
44
|
+
let action = "create";
|
|
45
|
+
if (await git.branchExists(branch)) {
|
|
46
|
+
action = "reuse-local";
|
|
47
|
+
const switched = await git.switchBranch(branch);
|
|
48
|
+
if (!switched.ok) { emit(ctx, opts, { ok: false, branch, action, stderr: switched.stderr }, `zero-branch: no pude cambiar a ${branch}: ${switched.stderr}`, "error"); return; }
|
|
49
|
+
} else if (await git.branchExists(branch, true)) {
|
|
50
|
+
action = "track-remote";
|
|
51
|
+
const switched = await git.run(["switch", "--track", `origin/${branch}`]);
|
|
52
|
+
if (!switched.ok) { emit(ctx, opts, { ok: false, branch, action, stderr: switched.stderr }, `zero-branch: no pude trackear origin/${branch}: ${switched.stderr}`, "error"); return; }
|
|
53
|
+
} else {
|
|
54
|
+
const created = await git.createBranch(branch, base);
|
|
55
|
+
if (!created.ok) { emit(ctx, opts, { ok: false, branch, action, stderr: created.stderr }, `zero-branch: no pude crear ${branch}: ${created.stderr}`, "error"); return; }
|
|
56
|
+
}
|
|
57
|
+
writeLinks(SDD_DIR, opts.slug, { branch, baseBranch: base });
|
|
58
|
+
emit(ctx, opts, { ok: true, branch, baseBranch: base, action }, `zero-branch: ${action} ${branch} (base ${base})`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
62
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
63
|
+
pi.registerCommand("zero-branch", { description: "Crea o cambia a la rama Git sdd/<slug> y la registra en links.json", handler: (args, ctx) => runZeroBranch(args, ctx).catch((err) => notify(ctx, `zero-branch: ${err instanceof Error ? err.message : String(err)}`, "error")) });
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { readRunRecords } from "./autotune.ts";
|
|
7
|
+
import { createGhRunner, type SpawnLike as GhSpawnLike } from "./gh-runner.ts";
|
|
8
|
+
import { createGitRunner, type GitRunner, type SpawnLike } from "./git-runner.ts";
|
|
9
|
+
import { readLinks } 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
|
+
export interface GitCheck { name: string; ok: boolean; message: string }
|
|
16
|
+
|
|
17
|
+
function parse(args: string): { slug: string; forMode: "pr" | "archive" | "any"; json: boolean } {
|
|
18
|
+
const out = { slug: "", forMode: "any" as "pr" | "archive" | "any", json: false };
|
|
19
|
+
for (const p of args.trim().split(/\s+/).filter(Boolean)) {
|
|
20
|
+
if (p === "--json") out.json = true;
|
|
21
|
+
else if (p.startsWith("--for=")) out.forMode = ["pr", "archive", "any"].includes(p.slice(6)) ? p.slice(6) as any : "any";
|
|
22
|
+
else if (!out.slug) out.slug = p;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function latestVerdict(slug: string): string | null {
|
|
27
|
+
const path = process.env.ZERO_RUNS_PATH || join(homedir(), ".pi", "zero-runs.jsonl");
|
|
28
|
+
let latest: { ts: string; verdict: string } | null = null;
|
|
29
|
+
for (const r of readRunRecords(path)) if (r.feature === slug && (!latest || r.ts > latest.ts)) latest = { ts: r.ts, verdict: r.verdict };
|
|
30
|
+
return latest?.verdict ?? null;
|
|
31
|
+
}
|
|
32
|
+
async function ghAvailable(spawnImpl: GhSpawnLike): Promise<boolean> { return Boolean((await createGhRunner({ spawn: spawnImpl }).detect()).data?.available); }
|
|
33
|
+
export async function validateGitState(slug: string, forMode: "pr" | "archive" | "any", git: GitRunner, ghSpawn: GhSpawnLike = spawn as unknown as GhSpawnLike): Promise<{ ok: boolean; checks: GitCheck[] }> {
|
|
34
|
+
const links = readLinks(SDD_DIR, slug);
|
|
35
|
+
const expectedBranch = typeof links.branch === "string" ? links.branch : null;
|
|
36
|
+
const checks: GitCheck[] = [];
|
|
37
|
+
const dirty = await git.isDirty(); checks.push({ name: "worktree", ok: !dirty, message: dirty ? "worktree has uncommitted changes" : "worktree clean" });
|
|
38
|
+
const current = await git.currentBranch(); checks.push({ name: "branch", ok: !expectedBranch || current === expectedBranch, message: expectedBranch ? `current ${current || "(detached)"}, expected ${expectedBranch}` : `current ${current || "(detached)"}` });
|
|
39
|
+
checks.push({ name: "remote", ok: await git.hasRemote("origin"), message: "origin remote configured" });
|
|
40
|
+
if (forMode === "pr") checks.push({ name: "gh-auth", ok: await ghAvailable(ghSpawn), message: "gh CLI available/authenticated" });
|
|
41
|
+
if (forMode === "pr" || forMode === "archive") { const verdict = latestVerdict(slug); checks.push({ name: "verdict", ok: verdict === "pasa", message: `latest verdict ${verdict ?? "—"}` }); }
|
|
42
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
43
|
+
}
|
|
44
|
+
export async function runZeroGitValidate(args: string, ctx: PiCommandContext, git: GitRunner = createGitRunner(spawn as unknown as SpawnLike), ghSpawn: GhSpawnLike = spawn as unknown as GhSpawnLike): Promise<void> {
|
|
45
|
+
const opts = parse(args);
|
|
46
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
47
|
+
if (!opts.slug) { notify("zero-git-validate: usage /zero-git-validate <slug> [--for=pr|archive|any] [--json]", "warning"); return; }
|
|
48
|
+
if (!existsSync(join(SDD_DIR, opts.slug))) { notify(`zero-git-validate: no existe .sdd/${opts.slug}`, "error"); return; }
|
|
49
|
+
const result = await validateGitState(opts.slug, opts.forMode, git, ghSpawn);
|
|
50
|
+
const text = result.checks.map((c) => `${c.ok ? "✅" : "❌"} ${c.name}: ${c.message}`).join("\n");
|
|
51
|
+
notify(opts.json ? JSON.stringify(result) : `zero-git-validate: ${result.ok ? "ok" : "falló"}\n${text}`, result.ok ? "info" : "error");
|
|
52
|
+
}
|
|
53
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
54
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
55
|
+
pi.registerCommand("zero-git-validate", { description: "Valida worktree, rama, remote, gh auth y veredicto antes de PR/archive", handler: (args, ctx) => runZeroGitValidate(args, ctx).catch((err) => { try { ctx.ui.notify(`zero-git-validate: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }) });
|
|
56
|
+
}
|
|
@@ -7,6 +7,7 @@ import { readRunRecords } from "./autotune.ts";
|
|
|
7
7
|
import { buildPrBody } from "./pr-body.ts";
|
|
8
8
|
import { createGhRunner, type SpawnLike } from "./gh-runner.ts";
|
|
9
9
|
import { readLinks, writeLinks } from "./sdd-links.ts";
|
|
10
|
+
import { validateArtifactSet, validateSpecInputs, validateTasksFile } from "./zero-validate.ts";
|
|
10
11
|
|
|
11
12
|
const SDD_DIR = ".sdd";
|
|
12
13
|
type NotifyType = "info" | "warning" | "error";
|
|
@@ -45,6 +46,18 @@ function writeTempBody(body: string): string {
|
|
|
45
46
|
return file;
|
|
46
47
|
}
|
|
47
48
|
function skippedNote(skipped: string[]): string { return skipped.length ? `; skipié labels que no existen en este repo: ${skipped.join(", ")}` : ""; }
|
|
49
|
+
function readSpecArtifacts(dir: string): { specMd: string; specs?: Record<string, string> } {
|
|
50
|
+
const flat = readFileOrEmpty(join(dir, "spec.md"));
|
|
51
|
+
const specsDir = join(dir, "specs");
|
|
52
|
+
const specs: Record<string, string> = {};
|
|
53
|
+
try {
|
|
54
|
+
for (const e of readdirSync(specsDir, { withFileTypes: true })) if (e.isDirectory()) {
|
|
55
|
+
const text = readFileOrEmpty(join(specsDir, e.name, "spec.md"));
|
|
56
|
+
if (text) specs[e.name] = text;
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
return Object.keys(specs).length ? { specMd: flat, specs } : { specMd: flat };
|
|
60
|
+
}
|
|
48
61
|
|
|
49
62
|
export async function runZeroPr(args: string, ctx: PiCommandContext, spawnImpl: SpawnLike = spawn as unknown as SpawnLike): Promise<void> {
|
|
50
63
|
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
@@ -54,18 +67,25 @@ export async function runZeroPr(args: string, ctx: PiCommandContext, spawnImpl:
|
|
|
54
67
|
const verdict = latestVerdict(slug);
|
|
55
68
|
if (verdict?.verdict !== "pasa") { notify(`zero-pr: ${slug} no tiene veredicto pasa (último: ${verdict?.verdict ?? "—"})`, "warning"); return; }
|
|
56
69
|
const dir = join(SDD_DIR, slug);
|
|
57
|
-
const artifacts = { proposalMd: readFileOrEmpty(join(dir, "proposal.md")),
|
|
70
|
+
const artifacts = { proposalMd: readFileOrEmpty(join(dir, "proposal.md")), ...readSpecArtifacts(dir), tasksMd: readFileOrEmpty(join(dir, "tasks.md")), verdictReasoning: verdict.reasoning };
|
|
71
|
+
const defects = [
|
|
72
|
+
...validateArtifactSet({ proposal: artifacts.proposalMd !== "", spec: artifacts.specMd !== "" || Boolean(artifacts.specs), design: existsSync(join(dir, "design.md")), tasks: artifacts.tasksMd !== "" }),
|
|
73
|
+
...validateSpecInputs(dir),
|
|
74
|
+
...validateTasksFile(artifacts.tasksMd),
|
|
75
|
+
].filter((d) => !d.kind.startsWith("missing-proposal"));
|
|
76
|
+
if (defects.length > 0) { notify(`zero-pr: validateForPr falló:\n${defects.map((d) => ` - [${d.kind}] ${d.message}`).join("\n")}`, "error"); return; }
|
|
58
77
|
const links = readLinks(SDD_DIR, slug);
|
|
59
78
|
const gh = createGhRunner({ spawn: spawnImpl });
|
|
60
79
|
const detected = await gh.detect();
|
|
61
80
|
if (!detected.data?.available) { notify(`zero-pr: ${detected.data?.hint ?? "gh CLI no disponible"}`, "error"); return; }
|
|
62
81
|
const { applied, skipped } = await labelIfExists(gh, labels);
|
|
63
|
-
const built = buildPrBody({ ...artifacts, linkedIssueNumber: typeof links.issueNumber === "number" ? links.issueNumber : undefined });
|
|
82
|
+
const built = buildPrBody({ slug, links, artifacts: { ...artifacts, linkedIssueNumber: typeof links.issueNumber === "number" ? links.issueNumber : undefined } });
|
|
64
83
|
const tmp = writeTempBody(built.body);
|
|
65
84
|
try {
|
|
66
|
-
const result = await gh.createPr({ title: built.title, bodyFile: tmp, labels: applied });
|
|
85
|
+
const result = await gh.createPr({ title: built.title, bodyFile: tmp, labels: applied, base: typeof links.baseBranch === "string" ? links.baseBranch : undefined, head: typeof links.branch === "string" ? links.branch : undefined } as any);
|
|
67
86
|
if (!result.ok) { notify(`zero-pr: gh pr create falló${result.stderr ? ` — ${result.stderr}` : ""}`, "error"); return; }
|
|
68
|
-
|
|
87
|
+
const parsedNumber = result.data?.number ?? (result.data?.url ? Number(/\/(?:pull|pulls)\/(\d+)(?:$|[?#])/.exec(result.data.url)?.[1]) : undefined);
|
|
88
|
+
writeLinks(SDD_DIR, slug, { prNumber: Number.isFinite(parsedNumber) ? parsedNumber : undefined, prUrl: result.data?.url, createdAt: new Date().toISOString() });
|
|
69
89
|
notify(`zero-pr: PR creado${result.data?.number ? ` #${result.data.number}` : ""}${result.data?.url ? ` ${result.data.url}` : ""}${skippedNote(skipped)}`, "info");
|
|
70
90
|
} finally {
|
|
71
91
|
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import { validateArtifactSet,
|
|
4
|
+
import { validateArtifactSet, validateSpecInputs, validateTasksFile, type ValidationDefect } from "./zero-validate.ts";
|
|
5
5
|
|
|
6
6
|
const SDD_DIR = ".sdd";
|
|
7
7
|
const ARTIFACTS = ["proposal", "spec", "design", "tasks"] as const;
|
|
@@ -38,12 +38,14 @@ function runValidate(args: string, ctx: PiCommandContext): void {
|
|
|
38
38
|
}
|
|
39
39
|
const dir = join(SDD_DIR, slug);
|
|
40
40
|
const texts = Object.fromEntries(ARTIFACTS.map((a) => [a, readFileOrNull(join(dir, `${a}.md`))])) as Record<(typeof ARTIFACTS)[number], string | null>;
|
|
41
|
-
const
|
|
41
|
+
const hasDomainSpec = existsSync(join(dir, "specs"));
|
|
42
|
+
const presence = Object.fromEntries(ARTIFACTS.map((a) => [a, a === "spec" ? texts.spec !== null || hasDomainSpec : texts[a] !== null])) as Record<(typeof ARTIFACTS)[number], boolean>;
|
|
42
43
|
const defects = validateArtifactSet(presence);
|
|
43
|
-
|
|
44
|
+
defects.push(...validateSpecInputs(dir));
|
|
44
45
|
if (texts.tasks !== null) defects.push(...validateTasksFile(texts.tasks));
|
|
46
|
+
for (const legacy of ["requirements.md"]) if (existsSync(join(dir, legacy))) defects.push({ kind: "legacy-artifact", path: legacy, message: `${legacy} is a legacy Kiro artifact; keep it only as historical context` });
|
|
45
47
|
|
|
46
|
-
const structural = defects.filter((d) => !d.kind.startsWith("missing-proposal"));
|
|
48
|
+
const structural = defects.filter((d) => !d.kind.startsWith("missing-proposal") && d.kind !== "legacy-artifact");
|
|
47
49
|
if (structural.length > 0) {
|
|
48
50
|
notify(`zero-validate: encontré defectos estructurales en ${slug}; revisalos antes de sync:\n${formatDefects(defects)}`, "error");
|
|
49
51
|
} else if (defects.length > 0) {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
1
4
|
import { checkGuardrails, parseDelta, type MergeError, type RenameBlock, type RequirementBlock } from "./spec-merge.ts";
|
|
2
5
|
|
|
3
6
|
export interface ValidationDefect {
|
|
@@ -14,6 +17,7 @@ export interface TaskRecord {
|
|
|
14
17
|
id: string;
|
|
15
18
|
title: string;
|
|
16
19
|
files: { path: string; isNew: boolean }[];
|
|
20
|
+
evidence: string | null;
|
|
17
21
|
review: number | null;
|
|
18
22
|
reviewRaw: string | null;
|
|
19
23
|
}
|
|
@@ -37,9 +41,9 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
for (const line of lines) {
|
|
40
|
-
const task = line.match(/^\s*- \[[ xX]\]\s+\*\*(T\d+)\.\s+([^*]+)\*\*/);
|
|
44
|
+
const task = line.match(/^\s*- \[[ xX]\]\s+\*\*(T\d+)\.\s+([^*]+)\*\*/) ?? line.match(/^###\s+(T\d+)\s+[—-]\s+(.+)$/) ?? line.match(/^##\s+\[[ xX]\]\s+(T\d+)\s*(?:[—-]\s*(.+))?$/);
|
|
41
45
|
if (task) {
|
|
42
|
-
current = { id: task[1], title: task[2].trim(), files: [], review: null, reviewRaw: null };
|
|
46
|
+
current = { id: task[1], title: (task[2] ?? "").trim(), files: [], evidence: null, review: null, reviewRaw: null };
|
|
43
47
|
tasks.push(current);
|
|
44
48
|
collectingFiles = false;
|
|
45
49
|
continue;
|
|
@@ -50,6 +54,11 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
50
54
|
pushFile(line);
|
|
51
55
|
continue;
|
|
52
56
|
}
|
|
57
|
+
if (/^\s*-\s+evidence:/.test(line)) {
|
|
58
|
+
collectingFiles = false;
|
|
59
|
+
current.evidence = line.replace(/^\s*-\s+evidence:\s*/, "").trim();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
53
62
|
if (/^\s*-\s+review:/.test(line)) {
|
|
54
63
|
collectingFiles = false;
|
|
55
64
|
const raw = line.replace(/^\s*-\s+review:\s*/, "").trim();
|
|
@@ -59,13 +68,14 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
59
68
|
continue;
|
|
60
69
|
}
|
|
61
70
|
if (collectingFiles) {
|
|
62
|
-
if (/^\s*`[^`]+`/.test(line) || /^\s+`[^`]+`/.test(line)) pushFile(line);
|
|
71
|
+
if (/^\s*`[^`]+`/.test(line) || /^\s+`[^`]+`/.test(line) || /^\s*-\s+`[^`]+`/.test(line)) pushFile(line);
|
|
63
72
|
else if (/^\s*-\s+/.test(line)) collectingFiles = false;
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
for (const task of tasks) {
|
|
68
77
|
if (task.files.length === 0) defects.push({ kind: "missing-files", task: task.id, message: `${task.id} is missing files list` });
|
|
78
|
+
if (task.evidence === null || task.evidence === "") defects.push({ kind: "missing-evidence", task: task.id, message: `${task.id} is missing evidence` });
|
|
69
79
|
if (task.reviewRaw === null) defects.push({ kind: "missing-review", task: task.id, message: `${task.id} is missing review estimate` });
|
|
70
80
|
else if (task.review === null) defects.push({ kind: "non-integer-review", task: task.id, message: `${task.id} review estimate is not an integer` });
|
|
71
81
|
}
|
|
@@ -124,6 +134,25 @@ export function validateSpecDelta(text: string): ValidationDefect[] {
|
|
|
124
134
|
return defects;
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
export function discoverSpecFiles(runDir: string): string[] {
|
|
138
|
+
const files: string[] = [];
|
|
139
|
+
if (existsSync(join(runDir, "spec.md"))) files.push(join(runDir, "spec.md"));
|
|
140
|
+
const specsDir = join(runDir, "specs");
|
|
141
|
+
try {
|
|
142
|
+
for (const e of readdirSync(specsDir, { withFileTypes: true })) if (e.isDirectory()) {
|
|
143
|
+
const file = join(specsDir, e.name, "spec.md");
|
|
144
|
+
if (existsSync(file)) files.push(file);
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
return files;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function validateSpecInputs(runDir: string): ValidationDefect[] {
|
|
151
|
+
const files = discoverSpecFiles(runDir);
|
|
152
|
+
if (files.length === 0) return [{ kind: "missing-spec", path: "spec.md", message: "spec.md or specs/<domain>/spec.md is missing" }];
|
|
153
|
+
return files.flatMap((file) => validateSpecDelta(readFileSync(file, "utf8")).map((d) => ({ ...d, path: file })));
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
export function validateArtifactSet(present: { proposal: boolean; spec: boolean; design: boolean; tasks: boolean }): ValidationDefect[] {
|
|
128
157
|
const defects: ValidationDefect[] = [];
|
|
129
158
|
for (const key of ["proposal", "spec", "design", "tasks"] as const) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.50",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (explore → plan → build → veredicto) with per-phase model autotune and token-efficient batched builds. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"./extensions/zero-validate-extension.ts",
|
|
37
37
|
"./extensions/zero-status-extension.ts",
|
|
38
38
|
"./extensions/zero-pr-extension.ts",
|
|
39
|
+
"./extensions/zero-branch-extension.ts",
|
|
40
|
+
"./extensions/zero-git-validate-extension.ts",
|
|
41
|
+
"./extensions/zero-archive-extension.ts",
|
|
39
42
|
"./extensions/zero-issue-extension.ts",
|
|
40
43
|
"./extensions/zero-diff-extension.ts",
|
|
41
44
|
"./extensions/provider-guard-extension.ts",
|
|
@@ -65,8 +68,13 @@
|
|
|
65
68
|
"extensions/zero-status-extension.ts",
|
|
66
69
|
"extensions/pr-body.ts",
|
|
67
70
|
"extensions/sdd-links.ts",
|
|
71
|
+
"extensions/sdd-config.ts",
|
|
72
|
+
"extensions/git-runner.ts",
|
|
68
73
|
"extensions/gh-runner.ts",
|
|
69
74
|
"extensions/zero-pr-extension.ts",
|
|
75
|
+
"extensions/zero-branch-extension.ts",
|
|
76
|
+
"extensions/zero-git-validate-extension.ts",
|
|
77
|
+
"extensions/zero-archive-extension.ts",
|
|
70
78
|
"extensions/zero-issue-extension.ts",
|
|
71
79
|
"extensions/zero-diff-extension.ts",
|
|
72
80
|
"extensions/provider-guard.ts",
|
package/prompts/orchestrator.md
CHANGED
|
@@ -177,7 +177,7 @@ A zero SDD run has two language surfaces — keep them apart.
|
|
|
177
177
|
- **Fixed identifiers — verbatim.** Never translate identifiers. Keep verdict
|
|
178
178
|
values (`pasa`, `corregir`, `replantear`, `cap-reached`), feature slugs,
|
|
179
179
|
file and directory paths, model ids, and command names (`/forge`,
|
|
180
|
-
`/zero-sync`) exactly as they are, even inside Spanish text.
|
|
180
|
+
`/zero-sync`, `/zero-branch`, `/zero-git-validate`, `/zero-pr`, `/zero-archive`) exactly as they are, even inside Spanish text.
|
|
181
181
|
|
|
182
182
|
## Output Contract
|
|
183
183
|
|
|
@@ -361,6 +361,14 @@ verdict" above). If the push fails for any reason, or zero runs with `--no-mcp`
|
|
|
361
361
|
or Cortex is down — emit a non-blocking warning and continue, never block the
|
|
362
362
|
run. The local line already stands.
|
|
363
363
|
|
|
364
|
+
## Git/PR/archive commands
|
|
365
|
+
|
|
366
|
+
Recommended command order for an audit-ready SDD change is: `/zero-branch <slug>` → `/zero-issue <slug>` → `/forge <slug>` (plan/build/veredicto) → `/zero-git-validate <slug> --for=pr` → `/zero-pr <slug>` → `/zero-archive <slug>` after `pasa`.
|
|
367
|
+
|
|
368
|
+
- `/zero-branch <slug>` creates/reuses `sdd/<slug>` (configurable) and records `branch`/`baseBranch` in `links.json`.
|
|
369
|
+
- `/zero-git-validate <slug>` checks worktree, branch, remote, `gh auth`, and verdict gating without mutating.
|
|
370
|
+
- `/zero-archive <slug>` merges approved deltas into `.sdd/specs/` and moves the run to `.sdd/archive/YYYY-MM-DD-<slug>/`.
|
|
371
|
+
|
|
364
372
|
## Spec sync & archive
|
|
365
373
|
|
|
366
374
|
The project keeps a **canonical spec store** at `.sdd/specs/requirements.md` —
|
|
@@ -369,12 +377,13 @@ emits a delta `spec.md` against that store; once the run reaches a `pasa`
|
|
|
369
377
|
verdict the delta is folded back into the store.
|
|
370
378
|
|
|
371
379
|
**After a `pasa` verdict — and only then.** Alongside the Cortex save and the
|
|
372
|
-
`zero-runs.jsonl` append, invoke the **`/zero-
|
|
373
|
-
the run's feature slug explicitly. `/zero-
|
|
380
|
+
`zero-runs.jsonl` append, invoke the **`/zero-archive <slug>`** command, passing
|
|
381
|
+
the run's feature slug explicitly. `/zero-archive` is a real pi command — a
|
|
374
382
|
deterministic, unit-tested merge, not a prompt instruction — that reads
|
|
375
|
-
`.sdd/specs/requirements.md`
|
|
376
|
-
|
|
377
|
-
|
|
383
|
+
`.sdd/specs/requirements.md` or per-domain `.sdd/specs/<domain>/requirements.md`
|
|
384
|
+
and `.sdd/<slug>/spec.md` or `.sdd/<slug>/specs/<domain>/spec.md`, folds the
|
|
385
|
+
delta into the store, writes atomically, moves the run to `.sdd/archive/`, and
|
|
386
|
+
records `archivePath` in `links.json`. You only call it; you never edit the store yourself.
|
|
378
387
|
|
|
379
388
|
**Never sync on a non-`pasa` outcome.** Do **not** invoke `/zero-sync` for a
|
|
380
389
|
`corregir` or `replantear` verdict, or when the iteration cap was reached
|
package/prompts/phases/plan.md
CHANGED
|
@@ -62,6 +62,38 @@ Write all four into `.sdd/<slug>/`:
|
|
|
62
62
|
absolute (or code-root-relative) paths it creates or edits, marking new files
|
|
63
63
|
`(new)`, so the build phase edits them directly instead of rediscovering them.
|
|
64
64
|
|
|
65
|
+
## Task schema
|
|
66
|
+
|
|
67
|
+
Use this exact checklist shape so build/validate/review can parse it without rediscovery:
|
|
68
|
+
|
|
69
|
+
```markdown
|
|
70
|
+
### T001 — Implement focused capability
|
|
71
|
+
|
|
72
|
+
- files:
|
|
73
|
+
- `<root>/src/example.ts`
|
|
74
|
+
- `<root>/src/example.test.ts` (new)
|
|
75
|
+
- detail: concrete implementation notes and boundaries.
|
|
76
|
+
- evidence: `npm test -- example` passes, or the exact manual check expected.
|
|
77
|
+
- review: ~120 changed lines
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Rules:
|
|
81
|
+
- Task ids are monotonic `T###`; keep existing completed ids stable on resume.
|
|
82
|
+
- Add `[P]` only for tasks that are truly parallel-safe, and `[Story:S1]`/`[Story:S2]` when the plan has independently testable stories.
|
|
83
|
+
- `files:` is mandatory and uses exact paths; append `(new)` for created files.
|
|
84
|
+
- `evidence:` is mandatory and names a concrete verification command or artifact.
|
|
85
|
+
- Keep `## Review Workload` present and synchronized with the task list.
|
|
86
|
+
|
|
87
|
+
## Constitution / Steering check
|
|
88
|
+
|
|
89
|
+
Before finalizing tasks, check project steering/constitution files when present (`.sdd/constitution.md`, `.sdd/steering.md`, `.kiro/steering/*`, or project equivalents). If absent, mark `n/a`; absence is not a blocker. Include this table in `design.md` or `tasks.md`:
|
|
90
|
+
|
|
91
|
+
| rule | status | waiver |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| Steering/constitution present | n/a | No local steering file found |
|
|
94
|
+
| Scope matches product/tech constraints | pass | — |
|
|
95
|
+
| No forbidden dependency or workflow change | pass | — |
|
|
96
|
+
|
|
65
97
|
Honour the prior-run lessons carried in the explore findings: when a past run
|
|
66
98
|
was sent back with `replantear`, do not repeat the plan mistake it recorded.
|
|
67
99
|
|
|
@@ -39,6 +39,21 @@ Review the build adversarially, with a fresh perspective. Check it against the
|
|
|
39
39
|
plan's requirements, run the tests yourself, and look for gaps, regressions,
|
|
40
40
|
and unmet acceptance criteria.
|
|
41
41
|
|
|
42
|
+
## Constitution / Steering gate
|
|
43
|
+
|
|
44
|
+
Re-check the plan's Constitution/Steering table before choosing a verdict. If a
|
|
45
|
+
present steering or constitution rule is violated and the plan did not record an
|
|
46
|
+
explicit waiver, return `corregir` even if the code compiles. If steering files
|
|
47
|
+
are absent, treat the row as `n/a`, not as a failure.
|
|
48
|
+
|
|
49
|
+
Example gate table to cite in reasoning when relevant:
|
|
50
|
+
|
|
51
|
+
| rule | status | waiver |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| Steering/constitution present | n/a | No local steering file found |
|
|
54
|
+
| Scope matches product/tech constraints | pass | — |
|
|
55
|
+
| No forbidden dependency or workflow change | fail | corregir: added undeclared dependency |
|
|
56
|
+
|
|
42
57
|
Record exactly one verdict:
|
|
43
58
|
|
|
44
59
|
- `pasa` — the build meets the plan; the run finishes successfully.
|
|
@@ -54,3 +54,12 @@ invoke `/forge` explicitly. A missed natural-language route is harmless — the
|
|
|
54
54
|
user can still type `/forge`. A wrong route forces routine work through the
|
|
55
55
|
heavy pipeline, which is not. Triggering only on a clear signal phrase is the
|
|
56
56
|
rule; `/forge` remains the explicit primary entry point.
|
|
57
|
+
|
|
58
|
+
## Related zero commands
|
|
59
|
+
|
|
60
|
+
- `/zero-branch <slug>` — create/reuse the configured Git branch for the SDD run and persist it in `.sdd/<slug>/links.json`.
|
|
61
|
+
- `/zero-git-validate <slug>` — validate worktree, branch, remote, GitHub CLI auth, and verdict gating before PR/archive.
|
|
62
|
+
- `/zero-pr <slug>` — create an audit-ready GitHub PR from SDD artifacts after `veredicto` returns `pasa`.
|
|
63
|
+
- `/zero-archive <slug>` — merge approved deltas into `.sdd/specs/` and move the run to `.sdd/archive/YYYY-MM-DD-<slug>/`.
|
|
64
|
+
|
|
65
|
+
Recommended flow: `/zero-branch` → `/zero-issue` → `/forge` → `/zero-git-validate --for=pr` → `/zero-pr` → `/zero-archive`.
|