@gonrocca/zero-pi 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,59 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { isEmptyDelta, mergeDelta, parseDelta, type MergeSummary } from "./spec-merge.ts";
5
+
6
+ const SDD_DIR = ".sdd";
7
+ const STORE_FILE = join(".sdd", "specs", "requirements.md");
8
+
9
+ type NotifyType = "info" | "warning" | "error";
10
+ interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
11
+ interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
12
+
13
+ function readFileOrNull(path: string): string | null { try { return readFileSync(path, "utf8"); } catch { return null; } }
14
+ function resolveSlug(arg: string): string | null {
15
+ const explicit = arg.trim();
16
+ if (explicit) return explicit;
17
+ try {
18
+ const candidates = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive" && existsSync(join(SDD_DIR, e.name, "spec.md"))).map((e) => e.name);
19
+ return candidates.length === 1 ? candidates[0] : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ export function formatDiffReport(summary: MergeSummary): string {
24
+ const lines = ["zero-diff: delta aplicaría estos cambios:"];
25
+ if (summary.added.length) lines.push(` agregados: ${summary.added.join(", ")}`);
26
+ if (summary.modified.length) lines.push(` modificados: ${summary.modified.join(", ")}`);
27
+ if (summary.renamed.length) lines.push(` renombrados: ${summary.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
28
+ if (summary.removed.length) lines.push(` eliminados: ${summary.removed.join(", ")}`);
29
+ return lines.join("\n");
30
+ }
31
+
32
+ function runDiff(args: string, ctx: PiCommandContext): void {
33
+ const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
34
+ const slug = resolveSlug(args);
35
+ if (slug === null) { notify("zero-diff: no hay un único run — corré /zero-diff <slug>", "warning"); return; }
36
+ const deltaPath = join(SDD_DIR, slug, "spec.md");
37
+ const deltaText = readFileOrNull(deltaPath);
38
+ if (deltaText === null) { notify(`zero-diff: ${deltaPath} no existe — no hay delta para comparar`, "warning"); return; }
39
+ const storeText = readFileOrNull(STORE_FILE) ?? "";
40
+ const result = mergeDelta(storeText, deltaText);
41
+ if (!result.ok) {
42
+ const detail = result.errors.map((e) => ` - [${e.kind}] ${e.message}`).join("\n");
43
+ notify(`zero-diff: store NO se podría actualizar — el delta tiene errores de guardrail:\n${detail}`, "error");
44
+ return;
45
+ }
46
+ if (isEmptyDelta(parseDelta(deltaText))) notify(`zero-diff: delta vacío en ${deltaPath} — nada cambiaría`, "info");
47
+ else notify(formatDiffReport(result.summary), "info");
48
+ }
49
+
50
+ export default function register(pi?: PiExtensionAPI): void {
51
+ if (!pi || typeof pi.registerCommand !== "function") return;
52
+ pi.registerCommand("zero-diff", {
53
+ description: "Muestra el diff lógico de un spec.md delta sin escribir archivos",
54
+ handler: (args: string, ctx: PiCommandContext): void => {
55
+ try { if (ctx?.ui?.notify) runDiff(args, ctx); }
56
+ catch (err) { try { ctx.ui.notify(`zero-diff: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,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
+ }
@@ -0,0 +1,51 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { buildIssueBody } from "./pr-body.ts";
7
+ import { createGhRunner, type SpawnLike } from "./gh-runner.ts";
8
+ import { writeLinks } from "./sdd-links.ts";
9
+
10
+ const SDD_DIR = ".sdd";
11
+ type NotifyType = "info" | "warning" | "error";
12
+ interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
13
+ interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
14
+ function readFileOrEmpty(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
15
+ function resolveSlug(arg: string): string | null { if (arg.trim()) return arg.trim(); try { const c = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive").map((e) => e.name); return c.length === 1 ? c[0] : null; } catch { return null; } }
16
+ function parseArgs(args: string): { slugArg: string; labels: string[] } { const parts = args.trim().split(/\s+/).filter(Boolean); let slugArg = ""; let type: string | undefined; for (const p of parts) p.startsWith("--type=") ? type = p.slice(7) : slugArg ||= p; return { slugArg, labels: type ? [type] : ["type:feature"] }; }
17
+ function normalizeTitle(s: string): string { return s.toLowerCase().replace(/\s+/g, " ").trim(); }
18
+ async function labelIfExists(gh: ReturnType<typeof createGhRunner>, candidates: string[]): Promise<{ applied: string[]; skipped: string[] }> { const labels = await gh.listLabels(); const existing = new Set(labels.ok ? (labels.data ?? []) : []); return { applied: candidates.filter((l) => existing.has(l)), skipped: candidates.filter((l) => !existing.has(l)) }; }
19
+ function writeTempBody(body: string): string { const file = join(tmpdir(), `zero-issue-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`); writeFileSync(file, body, "utf8"); return file; }
20
+ function skippedNote(skipped: string[]): string { return skipped.length ? `; skipié labels que no existen en este repo: ${skipped.join(", ")}` : ""; }
21
+
22
+ export async function runZeroIssue(args: string, ctx: PiCommandContext, spawnImpl: SpawnLike = spawn as unknown as SpawnLike): Promise<void> {
23
+ const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
24
+ const { slugArg, labels } = parseArgs(args);
25
+ const slug = resolveSlug(slugArg);
26
+ if (slug === null) { notify("zero-issue: no hay un único run — corré /zero-issue <slug>", "warning"); return; }
27
+ const dir = join(SDD_DIR, slug);
28
+ const built = buildIssueBody({ proposalMd: readFileOrEmpty(join(dir, "proposal.md")), specMd: readFileOrEmpty(join(dir, "spec.md")) });
29
+ const gh = createGhRunner({ spawn: spawnImpl });
30
+ const detected = await gh.detect();
31
+ if (!detected.data?.available) { notify(`zero-issue: ${detected.data?.hint ?? "gh CLI no disponible"}`, "error"); return; }
32
+ const dup = await gh.searchIssues(built.title);
33
+ if (!dup.ok) { notify(`zero-issue: gh issue list falló${dup.stderr ? ` — ${dup.stderr}` : ""}`, "error"); return; }
34
+ const existing = dup.data?.find((i) => normalizeTitle(i.title) === normalizeTitle(built.title));
35
+ if (existing) { writeLinks(SDD_DIR, slug, { issueNumber: existing.number, issueUrl: existing.url, createdAt: new Date().toISOString() }); notify(`zero-issue: ya existe el issue #${existing.number}`, "info"); return; }
36
+ const { applied, skipped } = await labelIfExists(gh, labels);
37
+ const tmp = writeTempBody(built.body);
38
+ try {
39
+ const created = await gh.createIssue({ title: built.title, bodyFile: tmp, labels: applied });
40
+ if (!created.ok) { notify(`zero-issue: gh issue create falló${created.stderr ? ` — ${created.stderr}` : ""}`, "error"); return; }
41
+ writeLinks(SDD_DIR, slug, { issueNumber: created.data?.number, issueUrl: created.data?.url, createdAt: new Date().toISOString() });
42
+ notify(`zero-issue: issue creado${created.data?.number ? ` #${created.data.number}` : ""}${created.data?.url ? ` ${created.data.url}` : ""}${skippedNote(skipped)}`, "info");
43
+ } finally {
44
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
45
+ }
46
+ }
47
+
48
+ export default function register(pi?: PiExtensionAPI): void {
49
+ if (!pi || typeof pi.registerCommand !== "function") return;
50
+ pi.registerCommand("zero-issue", { description: "Crea o enlaza un issue de GitHub desde .sdd/<slug>", handler: (args, ctx) => runZeroIssue(args, ctx).catch((err) => { try { ctx.ui.notify(`zero-issue: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }) });
51
+ }
@@ -0,0 +1,98 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { readRunRecords } from "./autotune.ts";
7
+ import { buildPrBody } from "./pr-body.ts";
8
+ import { createGhRunner, type SpawnLike } from "./gh-runner.ts";
9
+ import { readLinks, writeLinks } from "./sdd-links.ts";
10
+ import { validateArtifactSet, 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 readFileOrEmpty(path: string): string { try { return readFileSync(path, "utf8"); } catch { return ""; } }
18
+ function parseArgs(args: string): { slugArg: string; labels: string[] } {
19
+ const parts = args.trim().split(/\s+/).filter(Boolean);
20
+ let slugArg = "";
21
+ let type: string | undefined;
22
+ for (const p of parts) p.startsWith("--type=") ? type = p.slice(7) : slugArg ||= p;
23
+ return { slugArg, labels: type ? [type] : ["type:feature", "status:approved"] };
24
+ }
25
+ function resolveSlug(arg: string): string | null {
26
+ if (arg.trim()) return arg.trim();
27
+ try {
28
+ const candidates = readdirSync(SDD_DIR, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive").map((e) => e.name);
29
+ return candidates.length === 1 ? candidates[0] : null;
30
+ } catch { return null; }
31
+ }
32
+ function latestVerdict(slug: string): { verdict: string; reasoning?: string } | null {
33
+ const path = process.env.ZERO_RUNS_PATH || join(homedir(), ".pi", "zero-runs.jsonl");
34
+ let latest: { ts: string; verdict: string; reasoning?: string } | null = null;
35
+ for (const r of readRunRecords(path)) if (r.feature === slug && (!latest || r.ts > latest.ts)) latest = { ts: r.ts, verdict: r.verdict, reasoning: (r as any).reasoning ?? (r as any).verdictReasoning };
36
+ return latest;
37
+ }
38
+ async function labelIfExists(gh: ReturnType<typeof createGhRunner>, candidates: string[]): Promise<{ applied: string[]; skipped: string[] }> {
39
+ const labels = await gh.listLabels();
40
+ const existing = new Set(labels.ok ? (labels.data ?? []) : []);
41
+ return { applied: candidates.filter((l) => existing.has(l)), skipped: candidates.filter((l) => !existing.has(l)) };
42
+ }
43
+ function writeTempBody(body: string): string {
44
+ const file = join(tmpdir(), `zero-pr-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
45
+ writeFileSync(file, body, "utf8");
46
+ return file;
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
+ }
61
+
62
+ export async function runZeroPr(args: string, ctx: PiCommandContext, spawnImpl: SpawnLike = spawn as unknown as SpawnLike): Promise<void> {
63
+ const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
64
+ const { slugArg, labels } = parseArgs(args);
65
+ const slug = resolveSlug(slugArg);
66
+ if (slug === null) { notify("zero-pr: no hay un único run — corré /zero-pr <slug>", "warning"); return; }
67
+ const verdict = latestVerdict(slug);
68
+ if (verdict?.verdict !== "pasa") { notify(`zero-pr: ${slug} no tiene veredicto pasa (último: ${verdict?.verdict ?? "—"})`, "warning"); return; }
69
+ const dir = join(SDD_DIR, slug);
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; }
77
+ const links = readLinks(SDD_DIR, slug);
78
+ const gh = createGhRunner({ spawn: spawnImpl });
79
+ const detected = await gh.detect();
80
+ if (!detected.data?.available) { notify(`zero-pr: ${detected.data?.hint ?? "gh CLI no disponible"}`, "error"); return; }
81
+ const { applied, skipped } = await labelIfExists(gh, labels);
82
+ const built = buildPrBody({ slug, links, artifacts: { ...artifacts, linkedIssueNumber: typeof links.issueNumber === "number" ? links.issueNumber : undefined } });
83
+ const tmp = writeTempBody(built.body);
84
+ try {
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);
86
+ if (!result.ok) { notify(`zero-pr: gh pr create falló${result.stderr ? ` — ${result.stderr}` : ""}`, "error"); return; }
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() });
89
+ notify(`zero-pr: PR creado${result.data?.number ? ` #${result.data.number}` : ""}${result.data?.url ? ` ${result.data.url}` : ""}${skippedNote(skipped)}`, "info");
90
+ } finally {
91
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
92
+ }
93
+ }
94
+
95
+ export default function register(pi?: PiExtensionAPI): void {
96
+ if (!pi || typeof pi.registerCommand !== "function") return;
97
+ pi.registerCommand("zero-pr", { description: "Crea un PR de GitHub desde .sdd/<slug>", handler: (args, ctx) => runZeroPr(args, ctx).catch((err) => { try { ctx.ui.notify(`zero-pr: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }) });
98
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { readRunRecords } from "./autotune.ts";
6
+ import { readLinks, type LinksRecord } from "./sdd-links.ts";
7
+ import { buildStatus, type StatusRow } from "./zero-status.ts";
8
+
9
+ type NotifyType = "info" | "warning" | "error";
10
+ interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
11
+ interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
12
+
13
+ function listProjectSdd(): Record<string, string[]> {
14
+ const out: Record<string, string[]> = {};
15
+ for (const e of readdirSync(".sdd", { withFileTypes: true })) {
16
+ if (!e.isDirectory() || e.name === "specs" || e.name === "archive") continue;
17
+ out[e.name] = readdirSync(join(".sdd", e.name));
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function render(rows: StatusRow[]): string {
23
+ if (rows.length === 0) return "zero-status: no encontré runs en .sdd/.";
24
+ const width = Math.max(4, ...rows.map((r) => r.slug.length));
25
+ const lines = [`zero-status: runs detectados`, `slug${" ".repeat(width - 4)} artf sync verdict github`];
26
+ for (const row of rows) {
27
+ const art = [row.artifacts.proposal, row.artifacts.spec, row.artifacts.design, row.artifacts.tasks].map((v) => (v ? "✓" : "·")).join("");
28
+ lines.push(`${row.slug.padEnd(width)} ${art} ${row.synced ? "synced " : "pending"} ${row.lastVerdict ?? "—"} ${row.github ?? "—"}`);
29
+ }
30
+ return lines.join("\n");
31
+ }
32
+
33
+ function readLinksBySlug(slugs: readonly string[]): Record<string, LinksRecord> {
34
+ const out: Record<string, LinksRecord> = {};
35
+ for (const slug of slugs) out[slug] = readLinks(".sdd", slug);
36
+ return out;
37
+ }
38
+
39
+ function runStatus(ctx: PiCommandContext): void {
40
+ const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
41
+ let sddDir: Record<string, string[]> = {};
42
+ let archiveEntries: string[] = [];
43
+ if (existsSync(".sdd")) sddDir = listProjectSdd();
44
+ if (existsSync(join(".sdd", "archive"))) archiveEntries = readdirSync(join(".sdd", "archive"));
45
+ const runRecords = readRunRecords(join(homedir(), ".pi", "zero-runs.jsonl"));
46
+ const linksBySlug = readLinksBySlug(Object.keys(sddDir));
47
+ notify(render(buildStatus({ sddDir, archiveEntries, runRecords, linksBySlug })), "info");
48
+ }
49
+
50
+ export default function register(pi?: PiExtensionAPI): void {
51
+ if (!pi || typeof pi.registerCommand !== "function") return;
52
+ pi.registerCommand("zero-status", {
53
+ description: "Muestra estado de artefactos, sync y último veredicto de runs .sdd/",
54
+ handler: (_args: string, ctx: PiCommandContext): void => {
55
+ try { if (ctx?.ui?.notify) runStatus(ctx); }
56
+ catch (err) { try { ctx.ui.notify(`zero-status: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,65 @@
1
+ import type { RunRecord } from "./autotune.ts";
2
+
3
+ export interface StatusRow {
4
+ slug: string;
5
+ artifacts: { proposal: boolean; spec: boolean; design: boolean; tasks: boolean };
6
+ synced: boolean;
7
+ lastVerdict: string | null;
8
+ github?: string;
9
+ }
10
+
11
+ export interface GithubLinks { prNumber?: number; issueNumber?: number }
12
+
13
+ export function formatGithubCell(links: GithubLinks | undefined): string {
14
+ if (!links) return "—";
15
+ const parts: string[] = [];
16
+ if (typeof links.prNumber === "number") parts.push(`#${links.prNumber}`);
17
+ if (typeof links.issueNumber === "number") parts.push(`issue #${links.issueNumber}`);
18
+ return parts.length ? parts.join(" / ") : "—";
19
+ }
20
+
21
+ export function matchesArchiveEntry(slug: string, name: string): boolean {
22
+ const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ return new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${esc}(?:-\\d+)?$`).test(name);
24
+ }
25
+
26
+ export function latestVerdictBySlug(records: readonly Pick<RunRecord, "feature" | "ts" | "verdict">[]): Map<string, string> {
27
+ const latest = new Map<string, { ts: string; verdict: string }>();
28
+ for (const record of records) {
29
+ const prev = latest.get(record.feature);
30
+ if (!prev || record.ts > prev.ts) latest.set(record.feature, { ts: record.ts, verdict: record.verdict });
31
+ }
32
+ return new Map([...latest].map(([slug, v]) => [slug, v.verdict]));
33
+ }
34
+
35
+ export function buildStatus({
36
+ sddDir,
37
+ archiveEntries,
38
+ runRecords,
39
+ linksBySlug = {},
40
+ }: {
41
+ sddDir: Record<string, readonly string[]>;
42
+ archiveEntries: readonly string[];
43
+ runRecords: readonly Pick<RunRecord, "feature" | "ts" | "verdict">[];
44
+ linksBySlug?: Record<string, GithubLinks | undefined>;
45
+ }): StatusRow[] {
46
+ const verdicts = latestVerdictBySlug(runRecords);
47
+ return Object.keys(sddDir)
48
+ .filter((slug) => slug !== "specs" && slug !== "archive")
49
+ .sort((a, b) => a.localeCompare(b))
50
+ .map((slug) => {
51
+ const files = new Set(sddDir[slug]);
52
+ return {
53
+ slug,
54
+ artifacts: {
55
+ proposal: files.has("proposal.md"),
56
+ spec: files.has("spec.md"),
57
+ design: files.has("design.md"),
58
+ tasks: files.has("tasks.md"),
59
+ },
60
+ synced: archiveEntries.some((name) => matchesArchiveEntry(slug, name)),
61
+ lastVerdict: verdicts.get(slug) ?? null,
62
+ github: formatGithubCell(linksBySlug[slug]),
63
+ };
64
+ });
65
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { validateArtifactSet, validateSpecInputs, validateTasksFile, type ValidationDefect } from "./zero-validate.ts";
5
+
6
+ const SDD_DIR = ".sdd";
7
+ const ARTIFACTS = ["proposal", "spec", "design", "tasks"] as const;
8
+
9
+ type NotifyType = "info" | "warning" | "error";
10
+ interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
11
+ interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
12
+
13
+ function readFileOrNull(path: string): string | null {
14
+ try { return readFileSync(path, "utf8"); } catch { return null; }
15
+ }
16
+
17
+ function resolveSlug(arg: string): string | null {
18
+ const explicit = arg.trim();
19
+ if (explicit !== "") return explicit;
20
+ try {
21
+ const candidates = readdirSync(SDD_DIR, { withFileTypes: true })
22
+ .filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive" && existsSync(join(SDD_DIR, e.name, "spec.md")))
23
+ .map((e) => e.name);
24
+ return candidates.length === 1 ? candidates[0] : null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ function formatDefects(defects: ValidationDefect[]): string {
29
+ return defects.map((d) => ` - [${d.kind}] ${d.task ? `${d.task}: ` : ""}${d.message}`).join("\n");
30
+ }
31
+
32
+ function runValidate(args: string, ctx: PiCommandContext): void {
33
+ const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
34
+ const slug = resolveSlug(args);
35
+ if (slug === null) {
36
+ notify("zero-validate: no hay un único run para validar — corré /zero-validate <slug>", "warning");
37
+ return;
38
+ }
39
+ const dir = join(SDD_DIR, slug);
40
+ const texts = Object.fromEntries(ARTIFACTS.map((a) => [a, readFileOrNull(join(dir, `${a}.md`))])) as Record<(typeof ARTIFACTS)[number], string | null>;
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>;
43
+ const defects = validateArtifactSet(presence);
44
+ defects.push(...validateSpecInputs(dir));
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` });
47
+
48
+ const structural = defects.filter((d) => !d.kind.startsWith("missing-proposal") && d.kind !== "legacy-artifact");
49
+ if (structural.length > 0) {
50
+ notify(`zero-validate: encontré defectos estructurales en ${slug}; revisalos antes de sync:\n${formatDefects(defects)}`, "error");
51
+ } else if (defects.length > 0) {
52
+ notify(`zero-validate: ${slug} está usable, pero te falta un artefacto opcional:\n${formatDefects(defects)}`, "warning");
53
+ } else {
54
+ notify(`zero-validate: ${slug} está limpio — tenés propuesta, spec, diseño y tareas coherentes.`, "info");
55
+ }
56
+ }
57
+
58
+ export default function register(pi?: PiExtensionAPI): void {
59
+ if (!pi || typeof pi.registerCommand !== "function") return;
60
+ pi.registerCommand("zero-validate", {
61
+ description: "Valida artefactos .sdd/<slug>/ antes de sincronizar el spec store",
62
+ handler: (args: string, ctx: PiCommandContext): void => {
63
+ try { if (ctx?.ui?.notify) runValidate(args, ctx); }
64
+ catch (err) { try { ctx.ui.notify(`zero-validate: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
65
+ },
66
+ });
67
+ }