@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.
package/README.md CHANGED
@@ -67,7 +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. |
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. |
71
72
  | **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
72
73
  | **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
73
74
  | **Startup banner** | The violet ANSI-Shadow `ZERO` wordmark, drawn once at pi startup — `ZERO_HEADER=off` to disable. |
@@ -83,9 +84,76 @@ into `/forge` for you.
83
84
  | ------- | ---- |
84
85
  | `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
85
86
  | `/zero-models [<phase>=[<provider>/]<model>]` | Show or set per-phase models — `autotune=auto\|ask\|off`. |
86
- | `/zero-sync <slug>` | Fold a run's delta into the canonical spec store. |
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-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. |
90
+ | `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
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`. |
94
+ | `/zero-issue <slug>` | Create or reuse a GitHub issue for a run and persist the link. |
95
+ | `/zero-diff <slug>` | Preview the logical spec-store merge without writing files. |
87
96
  | `/zero-resume` | Write the session handoff note now. |
88
97
 
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
+
122
+
123
+ `/zero-pr <slug>` builds a PR title/body from `.sdd/<slug>/proposal.md`,
124
+ `spec.md`, `design.md`, and `tasks.md`, then saves the returned `prNumber` and
125
+ `prUrl` into `.sdd/<slug>/links.json`. It only runs after the run's latest
126
+ recorded verdict is `pasa`; if an issue was already linked, the PR body includes
127
+ `Closes #N`.
128
+
129
+ `/zero-issue <slug>` searches open GitHub issues for the exact normalized title
130
+ before creating a new one, then saves `issueNumber` and `issueUrl` in the same
131
+ `links.json`. Both commands require the `gh` CLI to be installed and
132
+ authenticated (`gh auth login`). `--type=<label>` is optional: when the label
133
+ exists in the repository it is passed to `gh`, and when it does not exist the
134
+ command continues without it.
135
+
136
+ The implementation shells out through `gh` rather than GitHub-specific Node
137
+ SDKs, keeping zero-pi dependency-free and portable across Windows, macOS, and
138
+ Linux as long as GitHub CLI is on `PATH`.
139
+
140
+ ### Spec deltas
141
+
142
+ `/zero-sync` and `/zero-diff` understand four delta sections: `## ADDED`, `## MODIFIED`, `## REMOVED`, and `## RENAMED`. `RENAMED` keeps the requirement's store position while changing its stable name:
143
+
144
+ ```md
145
+ ## RENAMED
146
+
147
+ ### REQ: new-name
148
+
149
+ from: old-name
150
+
151
+ Updated requirement body.
152
+
153
+ Acceptance criteria:
154
+ - ...
155
+ ```
156
+
89
157
  ## 🔧 Configuration
90
158
 
91
159
  zero-pi keeps its state in `~/.pi/zero.json` (per-phase models + autotune mode)
@@ -0,0 +1,79 @@
1
+ export interface GhResult<T = unknown> { ok: boolean; data?: T; stderr?: string; exitCode?: number }
2
+ export interface DetectData { available: boolean; version?: string; hint?: string }
3
+ export interface SpawnLike { (command: string, args: string[], options?: Record<string, unknown>): { stdout?: NodeJS.ReadableStream; stderr?: NodeJS.ReadableStream; on(event: string, cb: (...args: any[]) => void): unknown } }
4
+
5
+ function hint(): string {
6
+ return "no encontré el `gh` CLI. Instalalo:\n Windows: winget install GitHub.cli\n macOS: brew install gh\n Linux: apt install gh (o el equivalente de tu distro)\ny después corré `gh auth login` una vez.";
7
+ }
8
+
9
+ function run(spawn: SpawnLike, args: string[]): Promise<GhResult<string>> {
10
+ return new Promise((resolve) => {
11
+ let stdout = "";
12
+ let stderr = "";
13
+ let settled = false;
14
+ try {
15
+ const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
16
+ child.stdout?.on("data", (d) => { stdout += String(d); });
17
+ child.stderr?.on("data", (d) => { stderr += String(d); });
18
+ child.on("error", (err: Error) => {
19
+ if (!settled) { settled = true; resolve({ ok: false, stderr: err.message, exitCode: -1 }); }
20
+ });
21
+ child.on("close", (code: number) => {
22
+ if (!settled) { settled = true; resolve({ ok: code === 0, data: stdout.trim(), stderr: stderr.trim(), exitCode: code }); }
23
+ });
24
+ } catch (err) {
25
+ resolve({ ok: false, stderr: err instanceof Error ? err.message : String(err), exitCode: -1 });
26
+ }
27
+ });
28
+ }
29
+
30
+ function parseJson<T>(result: GhResult<string>, fallback: T): GhResult<T> {
31
+ if (!result.ok) return { ok: false, stderr: result.stderr, exitCode: result.exitCode };
32
+ if (!result.data) return { ok: true, data: fallback, stderr: result.stderr, exitCode: result.exitCode };
33
+ try { return { ok: true, data: JSON.parse(result.data) as T, stderr: result.stderr, exitCode: result.exitCode }; }
34
+ catch { return { ok: false, stderr: "gh devolvió JSON inválido", exitCode: result.exitCode }; }
35
+ }
36
+
37
+ function parseVersion(stdout: string | undefined): string | undefined {
38
+ const match = (stdout ?? "").match(/\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/);
39
+ return match?.[1];
40
+ }
41
+
42
+ function parseCreatedUrl(stdout: string | undefined, kind: "pull" | "issues"): { number?: number; url?: string } {
43
+ const lines = (stdout ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
44
+ const url = [...lines].reverse().find((l) => /^https:\/\/github\.com\/.+/.test(l));
45
+ const number = url ? Number((new RegExp(`/${kind}/(\\d+)(?:$|[?#])`).exec(url) ?? /\/(?:pull|pulls|issues)\/(\d+)(?:$|[?#])/.exec(url))?.[1]) : undefined;
46
+ return { url, number: Number.isFinite(number) ? number : undefined };
47
+ }
48
+
49
+ export function createGhRunner({ spawn }: { spawn: SpawnLike }) {
50
+ return {
51
+ async detect(): Promise<GhResult<DetectData>> {
52
+ const r = await run(spawn, ["--version"]);
53
+ return r.ok ? { ok: true, data: { available: true, version: parseVersion(r.data) } } : { ok: true, data: { available: false, hint: hint() }, stderr: r.stderr, exitCode: r.exitCode };
54
+ },
55
+ async listLabels(): Promise<GhResult<string[]>> {
56
+ const r = await run(spawn, ["label", "list", "--json", "name", "--limit", "200"]);
57
+ const parsed = parseJson<Array<{ name?: string }>>(r, []);
58
+ if (!parsed.ok) return parsed as GhResult<string[]>;
59
+ return { ok: true, data: (parsed.data ?? []).map((l) => l.name).filter((n): n is string => typeof n === "string") };
60
+ },
61
+ async createPr(input: { title: string; bodyFile: string; labels?: string[]; label?: string; base?: string; head?: string }): Promise<GhResult<{ number?: number; url?: string }>> {
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);
65
+ for (const label of input.labels ?? (input.label ? [input.label] : [])) args.push("--label", label);
66
+ const r = await run(spawn, args);
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 };
68
+ },
69
+ async searchIssues(query: string): Promise<GhResult<Array<{ number: number; title: string; url?: string }>>> {
70
+ return parseJson(await run(spawn, ["issue", "list", "--search", query, "--state", "open", "--json", "number,title,url", "--limit", "20"]), []);
71
+ },
72
+ async createIssue(input: { title: string; bodyFile: string; labels?: string[]; label?: string }): Promise<GhResult<{ number?: number; url?: string }>> {
73
+ const args = ["issue", "create", "--title", input.title, "--body-file", input.bodyFile];
74
+ for (const label of input.labels ?? (input.label ? [input.label] : [])) args.push("--label", label);
75
+ const r = await run(spawn, args);
76
+ return r.ok ? { ok: true, data: parseCreatedUrl(r.data, "issues"), stderr: r.stderr, exitCode: r.exitCode } : { ok: false, stderr: r.stderr, exitCode: r.exitCode };
77
+ },
78
+ };
79
+ }
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ export interface ArtifactTexts {
2
+ proposal?: string;
3
+ spec?: string;
4
+ specs?: Record<string, string>;
5
+ design?: string;
6
+ tasks?: string;
7
+ proposalMd?: string;
8
+ specMd?: string;
9
+ tasksMd?: string;
10
+ verdictReasoning?: string;
11
+ linkedIssueNumber?: number;
12
+ }
13
+
14
+ export interface PrBodyInput { slug?: string; links?: { issueNumber?: number; branch?: string; baseBranch?: string; prUrl?: string }; artifacts: ArtifactTexts }
15
+
16
+ export interface BuiltBody { title: string; body: string }
17
+
18
+ function normalize(text: string | undefined): string {
19
+ return (text ?? "").replace(/\r\n/g, "\n").trim();
20
+ }
21
+
22
+ function proposalOf(input: ArtifactTexts): string { return input.proposalMd ?? input.proposal ?? ""; }
23
+ function specOf(input: ArtifactTexts): string { return input.specMd ?? input.spec ?? ""; }
24
+ function tasksOf(input: ArtifactTexts): string { return input.tasksMd ?? input.tasks ?? ""; }
25
+ function placeholder(value: string, fallback = "<!-- vacío -->"): string { return normalize(value) || fallback; }
26
+
27
+ export function extractFirstParagraph(markdown: string | undefined): string {
28
+ const text = normalize(markdown);
29
+ if (text === "") return "";
30
+ const withoutTitle = text.replace(/^#\s+.*(?:\n+|$)/, "");
31
+ for (const block of withoutTitle.split(/\n{2,}/)) {
32
+ const cleaned = block.trim();
33
+ if (cleaned !== "" && !cleaned.startsWith("#")) return cleaned;
34
+ }
35
+ return "";
36
+ }
37
+
38
+ export function extractTitle(markdown: string | undefined, fallback = "run"): string {
39
+ const text = normalize(markdown);
40
+ const line = text.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
41
+ return (line || fallback).slice(0, 250);
42
+ }
43
+
44
+ export function extractSectionByHeading(markdown: string | undefined, heading: string): string {
45
+ const text = normalize(markdown);
46
+ if (text === "") return "";
47
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+ const re = new RegExp(`^##\\s+${escaped}\\s*$`, "im");
49
+ const match = re.exec(text);
50
+ if (!match) return "";
51
+ const start = match.index + match[0].length;
52
+ const rest = text.slice(start);
53
+ const next = /^##\s+/m.exec(rest);
54
+ return rest.slice(0, next ? next.index : undefined).trim();
55
+ }
56
+
57
+ export function extractTasksTable(tasks: string | undefined): string {
58
+ const text = normalize(tasks);
59
+ if (text === "") return "";
60
+ const taskMatches = [...text.matchAll(/^(?:##\s+\[[ xX]\]\s+|###\s+)(T\d+[^\n]*)/gm)];
61
+ if (taskMatches.length === 0) return "";
62
+ const rows = ["| Task | Files | Evidence |", "| --- | --- | --- |"];
63
+ for (const match of taskMatches) {
64
+ const title = match[1].trim();
65
+ const start = match.index ?? 0;
66
+ const next = text.slice(start + match[0].length).search(/^#{2,3}\s+/m);
67
+ const block = next === -1 ? text.slice(start) : text.slice(start, start + match[0].length + next);
68
+ if (!/^\s*-\s+files:\s*$/m.test(block)) continue;
69
+ const files = [...block.matchAll(/^\s+-\s+`([^`]+)`/gm)].map((m) => m[1]);
70
+ const evidence = (/^\s*-\s+evidence:\s*(.+)$/m.exec(block)?.[1] ?? "").trim();
71
+ if (files.length === 0) continue;
72
+ rows.push(`| ${title} | ${files.join("<br>")} | ${evidence || "—"} |`);
73
+ }
74
+ return rows.length > 2 ? rows.join("\n") : "";
75
+ }
76
+
77
+ export function extractAcceptanceCriteria(spec: string | undefined): string {
78
+ const text = normalize(spec);
79
+ if (text === "") return "";
80
+ const out: string[] = [];
81
+ const reqBlocks = text.split(/(?=^###\s+REQ:)/m).filter((b) => /^###\s+REQ:/m.test(b));
82
+ const blocks = reqBlocks.length ? reqBlocks : [text];
83
+ for (const block of blocks) {
84
+ const match = /Acceptance criteria:\s*\n([\s\S]*?)(?=\n#{2,3}\s+|\n\S(?![^\n]*\n\s*-)|$)/i.exec(block);
85
+ if (match?.[1]) {
86
+ const bullets = match[1].split("\n").map((l) => l.trim()).filter((l) => /^[-*]\s+/.test(l));
87
+ out.push(...bullets);
88
+ }
89
+ }
90
+ return out.join("\n");
91
+ }
92
+
93
+ function issueSections(input: ArtifactTexts): string[] {
94
+ const proposal = proposalOf(input);
95
+ const spec = specOf(input);
96
+ const sections: string[] = [];
97
+ const contexto = extractFirstParagraph(proposal);
98
+ if (contexto) sections.push("## Contexto", contexto);
99
+ const alcance = extractSectionByHeading(proposal, "Alcance");
100
+ if (alcance) sections.push("## Alcance", alcance);
101
+ const criteria = extractAcceptanceCriteria(spec);
102
+ if (criteria) sections.push("## Criterios de aceptación", criteria);
103
+ return sections;
104
+ }
105
+
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 ?? {}) };
110
+ const issueNumber = input.linkedIssueNumber ?? links.issueNumber;
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);
113
+ const body = [
114
+ "## 🔗 Linked Issue",
115
+ linkedIssue,
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
+ "",
126
+ "## 📝 Summary",
127
+ placeholder(extractFirstParagraph(proposalOf(input))),
128
+ "",
129
+ "## 📂 Changes",
130
+ placeholder(extractTasksTable(tasksOf(input))),
131
+ "",
132
+ "## 🧪 Test evidence",
133
+ placeholder(input.verdictReasoning ?? ""),
134
+ "",
135
+ "## Risks",
136
+ placeholder(extractSectionByHeading(proposalOf(input), "Riesgos") || extractSectionByHeading(input.design, "Riesgos")),
137
+ "",
138
+ "## Verdict",
139
+ placeholder(input.verdictReasoning ?? "Sin veredicto registrado."),
140
+ "",
141
+ "## ✅ Checklist",
142
+ "- [ ] Revisé el diff local",
143
+ "- [ ] Corrí los tests relevantes",
144
+ "- [ ] Actualicé artefactos SDD si correspondía",
145
+ ].join("\n");
146
+ return { title: extractTitle(proposalOf(input)), body };
147
+ }
148
+
149
+ export function buildIssueBody(input: ArtifactTexts): BuiltBody {
150
+ const body = issueSections(input).join("\n\n");
151
+ return { title: extractTitle(proposalOf(input)), body };
152
+ }
@@ -0,0 +1,104 @@
1
+ // zero-pi — filesystem-wide scan guard, pi wiring.
2
+ //
3
+ // A thin pi extension that wires the pure decision logic in `scan-guard.ts` to
4
+ // the `tool_call` hook. Before any shell tool runs, it reads the command and,
5
+ // when the command contains a `find`/`grep -r`/`rg` rooted at a filesystem root
6
+ // (`/`, a bare drive mount, `~`, …), blocks the call and returns a reason that
7
+ // tells the model to scope the search to the plan's code root instead.
8
+ //
9
+ // Why a hard block and not just the phase-prompt guidance: the veredicto prompt
10
+ // already says "do not run a filesystem-wide find/grep", yet a model ignored it
11
+ // and wedged the pipeline for 6+ hours on `find / -maxdepth 12 …` (OneDrive
12
+ // placeholder hydration never returns on Windows). A `tool_call` block is the
13
+ // enforcement the prompt cannot be.
14
+ //
15
+ // All decisions live in `scan-guard.ts`; this file declares the minimal pi-API
16
+ // surface it uses, hooks `tool_call`, and returns the block verdict. Both
17
+ // `register` and the handler are wrapped in a swallowing `try/catch` — a failure
18
+ // must never break a pi session, and (critically) must never block a tool by
19
+ // accident. Dependency-free: only `scan-guard.ts` is imported.
20
+
21
+ import { classifyShellCommand, GUARDED_TOOLS } from "./scan-guard.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Minimal local pi-API interfaces
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * The `tool_call` event pi emits before a tool runs. The shell command lives at
29
+ * `input.command`; older/other shapes may carry it at `args.command`, so the
30
+ * handler reads both defensively.
31
+ */
32
+ interface PiToolCallEvent {
33
+ toolName?: string;
34
+ input?: { command?: unknown };
35
+ args?: { command?: unknown };
36
+ }
37
+
38
+ /** What a `tool_call` handler may return to stop a tool from running. */
39
+ interface PiToolCallResult {
40
+ block: true;
41
+ reason: string;
42
+ }
43
+
44
+ /** The slice of pi's extension API the guard uses. */
45
+ interface PiExtensionAPI {
46
+ on(
47
+ event: string,
48
+ handler: (event: PiToolCallEvent, ctx: unknown) => PiToolCallResult | undefined,
49
+ ): void;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Handler
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Pull the shell command string out of either event shape. */
57
+ function readCommand(event: PiToolCallEvent): unknown {
58
+ return event?.input?.command ?? event?.args?.command;
59
+ }
60
+
61
+ /**
62
+ * The `tool_call` guard handler.
63
+ *
64
+ * Returns `{ block, reason }` only for a guarded shell tool whose command is a
65
+ * root-rooted scan; otherwise returns `undefined` (allow). Wrapped so any
66
+ * failure degrades to "allow" — the guard must never wedge a session by
67
+ * throwing, nor block a tool because of its own bug.
68
+ */
69
+ export function handleToolCall(event: PiToolCallEvent): PiToolCallResult | undefined {
70
+ try {
71
+ if (!event || typeof event.toolName !== "string") return undefined;
72
+ if (!GUARDED_TOOLS.has(event.toolName)) return undefined;
73
+
74
+ const decision = classifyShellCommand(readCommand(event));
75
+ if (decision.block && decision.reason) {
76
+ return { block: true, reason: decision.reason };
77
+ }
78
+ return undefined;
79
+ } catch {
80
+ // A guard failure degrades to "allow" — never block a tool by accident.
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Registration
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * The pi extension entry point.
91
+ *
92
+ * pi calls this once when the extension loads. It wires the guard handler to
93
+ * the `tool_call` event. The body is wrapped in a swallowing `try/catch`, and
94
+ * the handler is wrapped again, so neither registration nor a later failure can
95
+ * break a pi session. Called with no or an invalid `pi`, it no-ops cleanly.
96
+ */
97
+ export default function register(pi?: unknown): void {
98
+ try {
99
+ if (!pi || typeof (pi as PiExtensionAPI).on !== "function") return;
100
+ (pi as PiExtensionAPI).on("tool_call", handleToolCall);
101
+ } catch {
102
+ // Registration itself must never break a pi session.
103
+ }
104
+ }
@@ -0,0 +1,190 @@
1
+ // zero-pi — filesystem-wide scan guard, pure-logic module.
2
+ //
3
+ // An SDD subagent (most often `zero-veredicto`) sometimes tries to *rediscover*
4
+ // where the code lives by running an unbounded `find` from the filesystem root:
5
+ //
6
+ // find / -maxdepth 12 -type d -iname "*admin-data-keys*"
7
+ //
8
+ // On Windows this does not merely run slow — it hangs effectively forever.
9
+ // Git Bash's `/` traversal reaches the user tree under OneDrive, and `find`
10
+ // blocks indefinitely forcing the hydration of every cloud-only placeholder it
11
+ // touches. A real run was caught wedged on exactly this command for 6+ hours
12
+ // with no output, stalling the whole pipeline. The phase prompt already tells
13
+ // the agent not to do full-tree scans, but a prompt is guidance, not
14
+ // enforcement — a model under pressure ignored it.
15
+ //
16
+ // This module is the enforcement half: a pure, dependency-free classifier that
17
+ // decides whether a shell command contains a filesystem-wide scan rooted at a
18
+ // top-level location. The pi wiring (the `tool_call` handler that reads the
19
+ // command and returns `{ block, reason }`) lives in `scan-guard-extension.ts`.
20
+ // No pi imports, no filesystem, no side effects — testable with plain strings.
21
+ //
22
+ // Design intent — block the dangerous class, never legitimate scoped work:
23
+ // • Only `find` / `grep -r` / `rg` *rooted at a filesystem root* are blocked
24
+ // (`/`, a bare drive mount like `/c`, `C:\`, `~`, `$HOME`, …).
25
+ // • A scan scoped to a real subtree (`find /e/zero/.sdd -name …`,
26
+ // `rg foo src/`) is always allowed — the root token must be the *entire*
27
+ // search path, not a prefix of it.
28
+ // • `.` / `./` (the cwd, i.e. the code root) is always allowed.
29
+
30
+ /**
31
+ * The guard's decision for one shell command.
32
+ *
33
+ * - `block: false` — allow the command (the common case).
34
+ * - `block: true` — refuse it; `reason` explains why and how to scope it, and
35
+ * the wiring surfaces it back to the model as the blocked-tool reason.
36
+ */
37
+ export interface ScanGuardDecision {
38
+ block: boolean;
39
+ reason?: string;
40
+ }
41
+
42
+ /** Shell tool names whose `command` this guard inspects. */
43
+ export const GUARDED_TOOLS: ReadonlySet<string> = new Set(["bash", "shell", "sh"]);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Root-path detection
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Whether `token` denotes a filesystem *root* — a location so broad that a
51
+ * recursive scan of it traverses the whole machine (and, on Windows, hangs on
52
+ * OneDrive placeholder hydration).
53
+ *
54
+ * Matches, ignoring a single trailing slash:
55
+ * • POSIX root `/`
56
+ * • Git Bash drive mount `/c`, `/d`, … (a single letter, nothing deeper)
57
+ * • Windows drive root `C:`, `C:\`, `c:/`
58
+ * • Home `~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `%HOMEPATH%`
59
+ *
60
+ * Crucially it does NOT match a *scoped* path that merely starts at a root,
61
+ * e.g. `/e/zero/.sdd` or `C:\Users\gonza\proj` — those are bounded and allowed.
62
+ */
63
+ export function isRootPath(token: string): boolean {
64
+ // Strip surrounding quotes a shell would remove, and one trailing slash.
65
+ let t = token.trim().replace(/^['"]|['"]$/g, "");
66
+ if (t.length > 1) t = t.replace(/[/\\]$/, "");
67
+
68
+ if (t === "/" || t === "\\") return true;
69
+ if (t === "~") return true;
70
+
71
+ // Git Bash single-letter drive mount: /c, /d, … but not /c/foo.
72
+ if (/^\/[a-zA-Z]$/.test(t)) return true;
73
+
74
+ // Windows drive root: C:, C:\, c:/ — but not C:\Users.
75
+ if (/^[a-zA-Z]:[\\/]?$/.test(t)) return true;
76
+
77
+ // Home-directory environment variables, with or without braces.
78
+ const home = t.replace(/[/\\]$/, "");
79
+ if (home === "$HOME" || home === "${HOME}") return true;
80
+ if (/^%(USERPROFILE|HOMEPATH|HOMEDRIVE|HOME)%$/i.test(home)) return true;
81
+
82
+ return false;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Per-segment classification
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** Split a shell line into segments on `;`, `&&`, `||`, `|`, and newlines. */
90
+ export function splitSegments(command: string): string[] {
91
+ return command
92
+ .split(/\n|;|&&|\|\||\|/)
93
+ .map((s) => s.trim())
94
+ .filter((s) => s.length > 0);
95
+ }
96
+
97
+ /** Tokenize one segment on whitespace (quotes kept; good enough for path ops). */
98
+ function tokenize(segment: string): string[] {
99
+ return segment.split(/\s+/).filter((t) => t.length > 0);
100
+ }
101
+
102
+ /**
103
+ * Whether a single command segment is a root-rooted `find`.
104
+ *
105
+ * `find`'s path operands are the tokens after `find` and before the first
106
+ * expression primary (a token starting with `-`, `(`, `!`). If any path operand
107
+ * is a root path, the scan is unbounded.
108
+ */
109
+ function isRootedFind(tokens: string[]): boolean {
110
+ // Locate the `find` argv0, allowing an env-prefix like `command` is overkill;
111
+ // a leading path such as `/usr/bin/find` still ends in `find`.
112
+ const idx = tokens.findIndex((t) => t === "find" || /[/\\]find$/.test(t));
113
+ if (idx === -1) return false;
114
+
115
+ for (let i = idx + 1; i < tokens.length; i++) {
116
+ const tok = tokens[i];
117
+ if (tok.startsWith("-") || tok === "(" || tok === "!" || tok === ")") break; // expression begins
118
+ if (isRootPath(tok)) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /** Whether `tokens` invoke `grep` recursively (`-r`/`-R`/`--recursive`, incl. combined flags like `-rn`). */
124
+ function isRecursiveGrep(tokens: string[]): boolean {
125
+ const idx = tokens.findIndex((t) => t === "grep" || /[/\\]grep$/.test(t));
126
+ if (idx === -1) return false;
127
+ return tokens
128
+ .slice(idx + 1)
129
+ .some((t) => t === "--recursive" || /^-[a-zA-Z]*[rR]/.test(t));
130
+ }
131
+
132
+ /**
133
+ * Whether a `grep -r` / `rg` segment targets a root path.
134
+ *
135
+ * ripgrep recurses by default, so any root target is dangerous; plain `grep`
136
+ * is only dangerous with a recursive flag. The path target of either is taken
137
+ * to be any non-flag operand that is a root path.
138
+ */
139
+ function isRootedRecursiveSearch(tokens: string[]): boolean {
140
+ const isRg = tokens.some((t) => t === "rg" || /[/\\]rg$/.test(t));
141
+ const isGrep = tokens.some((t) => t === "grep" || /[/\\]grep$/.test(t));
142
+ if (!isRg && !isGrep) return false;
143
+ if (isGrep && !isRg && !isRecursiveGrep(tokens)) return false;
144
+
145
+ // Any bare (non-flag) operand that is a root path is a whole-machine scan.
146
+ return tokens.some((t) => !t.startsWith("-") && isRootPath(t));
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Public classifier
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /** The reason returned to the model when a root-rooted scan is blocked. */
154
+ export function blockReason(command: string): string {
155
+ const offending = splitSegments(command).find((seg) => {
156
+ const tokens = tokenize(seg);
157
+ return isRootedFind(tokens) || isRootedRecursiveSearch(tokens);
158
+ });
159
+ return (
160
+ `zero scan-guard: blocked a filesystem-wide scan` +
161
+ (offending ? ` (\`${offending}\`)` : "") +
162
+ `. On Windows a \`find\`/\`grep -r\`/\`rg\` rooted at \`/\`, a drive root, or \`~\` ` +
163
+ `hangs indefinitely hydrating OneDrive placeholders — it does not just run slow. ` +
164
+ `Do not rediscover code with a full-tree scan: read the code root from the plan ` +
165
+ `(\`## Code roots\` in design.md, or the \`Code root:\` line in tasks.md / your task input) ` +
166
+ `and scope the search to that absolute path (e.g. \`find <code-root> -name …\`).`
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Classify a shell command into an allow/block decision.
172
+ *
173
+ * Pure and total — never throws. Blocks when any `;`/`&&`/`|`-separated segment
174
+ * is a `find`, recursive `grep`, or `rg` whose search path is a filesystem root
175
+ * (see {@link isRootPath}). A non-string or empty command is allowed (the guard
176
+ * never invents a reason to block).
177
+ */
178
+ export function classifyShellCommand(command: unknown): ScanGuardDecision {
179
+ if (typeof command !== "string" || command.trim() === "") {
180
+ return { block: false };
181
+ }
182
+
183
+ for (const segment of splitSegments(command)) {
184
+ const tokens = tokenize(segment);
185
+ if (isRootedFind(tokens) || isRootedRecursiveSearch(tokens)) {
186
+ return { block: true, reason: blockReason(command) };
187
+ }
188
+ }
189
+ return { block: false };
190
+ }