@gonrocca/zero-pi 0.1.48 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/extensions/gh-runner.ts +77 -0
- package/extensions/pr-body.ts +129 -0
- package/extensions/scan-guard-extension.ts +104 -0
- package/extensions/scan-guard.ts +190 -0
- package/extensions/sdd-links.ts +38 -0
- package/extensions/spec-merge-extension.ts +4 -0
- package/extensions/spec-merge.ts +105 -215
- package/extensions/zero-diff-extension.ts +59 -0
- package/extensions/zero-issue-extension.ts +51 -0
- package/extensions/zero-pr-extension.ts +78 -0
- package/extensions/zero-status-extension.ts +59 -0
- package/extensions/zero-status.ts +65 -0
- package/extensions/zero-validate-extension.ts +65 -0
- package/extensions/zero-validate.ts +133 -0
- package/package.json +20 -2
- package/prompts/phases/explore.md +6 -0
- package/prompts/phases/plan.md +5 -0
- package/prompts/phases/veredicto.md +24 -11
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ into `/forge` for you.
|
|
|
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
70
|
| **`/zero-sync`** | Folds each run's spec delta into a canonical, project-wide spec store. |
|
|
71
|
+
| **PR / Issues** | `/zero-pr <slug>` and `/zero-issue <slug>` create GitHub tracking links from SDD artifacts. |
|
|
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,50 @@ 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-validate <slug>` | Validate a run's proposal/spec/design/tasks artifacts before sync. |
|
|
89
|
+
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
90
|
+
| `/zero-pr <slug>` | Create a GitHub PR from a run that already has verdict `pasa`. |
|
|
91
|
+
| `/zero-issue <slug>` | Create or reuse a GitHub issue for a run and persist the link. |
|
|
92
|
+
| `/zero-diff <slug>` | Preview the logical spec-store merge without writing files. |
|
|
87
93
|
| `/zero-resume` | Write the session handoff note now. |
|
|
88
94
|
|
|
95
|
+
### PR / Issues
|
|
96
|
+
|
|
97
|
+
`/zero-pr <slug>` builds a PR title/body from `.sdd/<slug>/proposal.md`,
|
|
98
|
+
`spec.md`, `design.md`, and `tasks.md`, then saves the returned `prNumber` and
|
|
99
|
+
`prUrl` into `.sdd/<slug>/links.json`. It only runs after the run's latest
|
|
100
|
+
recorded verdict is `pasa`; if an issue was already linked, the PR body includes
|
|
101
|
+
`Closes #N`.
|
|
102
|
+
|
|
103
|
+
`/zero-issue <slug>` searches open GitHub issues for the exact normalized title
|
|
104
|
+
before creating a new one, then saves `issueNumber` and `issueUrl` in the same
|
|
105
|
+
`links.json`. Both commands require the `gh` CLI to be installed and
|
|
106
|
+
authenticated (`gh auth login`). `--type=<label>` is optional: when the label
|
|
107
|
+
exists in the repository it is passed to `gh`, and when it does not exist the
|
|
108
|
+
command continues without it.
|
|
109
|
+
|
|
110
|
+
The implementation shells out through `gh` rather than GitHub-specific Node
|
|
111
|
+
SDKs, keeping zero-pi dependency-free and portable across Windows, macOS, and
|
|
112
|
+
Linux as long as GitHub CLI is on `PATH`.
|
|
113
|
+
|
|
114
|
+
### Spec deltas
|
|
115
|
+
|
|
116
|
+
`/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:
|
|
117
|
+
|
|
118
|
+
```md
|
|
119
|
+
## RENAMED
|
|
120
|
+
|
|
121
|
+
### REQ: new-name
|
|
122
|
+
|
|
123
|
+
from: old-name
|
|
124
|
+
|
|
125
|
+
Updated requirement body.
|
|
126
|
+
|
|
127
|
+
Acceptance criteria:
|
|
128
|
+
- ...
|
|
129
|
+
```
|
|
130
|
+
|
|
89
131
|
## 🔧 Configuration
|
|
90
132
|
|
|
91
133
|
zero-pi keeps its state in `~/.pi/zero.json` (per-phase models + autotune mode)
|
|
@@ -0,0 +1,77 @@
|
|
|
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)?.[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 }): Promise<GhResult<{ number?: number; url?: string }>> {
|
|
62
|
+
const args = ["pr", "create", "--title", input.title, "--body-file", input.bodyFile];
|
|
63
|
+
for (const label of input.labels ?? (input.label ? [input.label] : [])) args.push("--label", label);
|
|
64
|
+
const r = await run(spawn, args);
|
|
65
|
+
return r.ok ? { ok: true, data: parseCreatedUrl(r.data, "pull"), stderr: r.stderr, exitCode: r.exitCode } : { ok: false, stderr: r.stderr, exitCode: r.exitCode };
|
|
66
|
+
},
|
|
67
|
+
async searchIssues(query: string): Promise<GhResult<Array<{ number: number; title: string; url?: string }>>> {
|
|
68
|
+
return parseJson(await run(spawn, ["issue", "list", "--search", query, "--state", "open", "--json", "number,title,url", "--limit", "20"]), []);
|
|
69
|
+
},
|
|
70
|
+
async createIssue(input: { title: string; bodyFile: string; labels?: string[]; label?: string }): Promise<GhResult<{ number?: number; url?: string }>> {
|
|
71
|
+
const args = ["issue", "create", "--title", input.title, "--body-file", input.bodyFile];
|
|
72
|
+
for (const label of input.labels ?? (input.label ? [input.label] : [])) args.push("--label", label);
|
|
73
|
+
const r = await run(spawn, args);
|
|
74
|
+
return r.ok ? { ok: true, data: parseCreatedUrl(r.data, "issues"), stderr: r.stderr, exitCode: r.exitCode } : { ok: false, stderr: r.stderr, exitCode: r.exitCode };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export interface ArtifactTexts {
|
|
2
|
+
proposal?: string;
|
|
3
|
+
spec?: string;
|
|
4
|
+
design?: string;
|
|
5
|
+
tasks?: string;
|
|
6
|
+
proposalMd?: string;
|
|
7
|
+
specMd?: string;
|
|
8
|
+
tasksMd?: string;
|
|
9
|
+
verdictReasoning?: string;
|
|
10
|
+
linkedIssueNumber?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BuiltBody { title: string; body: string }
|
|
14
|
+
|
|
15
|
+
function normalize(text: string | undefined): string {
|
|
16
|
+
return (text ?? "").replace(/\r\n/g, "\n").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function proposalOf(input: ArtifactTexts): string { return input.proposalMd ?? input.proposal ?? ""; }
|
|
20
|
+
function specOf(input: ArtifactTexts): string { return input.specMd ?? input.spec ?? ""; }
|
|
21
|
+
function tasksOf(input: ArtifactTexts): string { return input.tasksMd ?? input.tasks ?? ""; }
|
|
22
|
+
function placeholder(value: string, fallback = "<!-- vacío -->"): string { return normalize(value) || fallback; }
|
|
23
|
+
|
|
24
|
+
export function extractFirstParagraph(markdown: string | undefined): string {
|
|
25
|
+
const text = normalize(markdown);
|
|
26
|
+
if (text === "") return "";
|
|
27
|
+
const withoutTitle = text.replace(/^#\s+.*(?:\n+|$)/, "");
|
|
28
|
+
for (const block of withoutTitle.split(/\n{2,}/)) {
|
|
29
|
+
const cleaned = block.trim();
|
|
30
|
+
if (cleaned !== "" && !cleaned.startsWith("#")) return cleaned;
|
|
31
|
+
}
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractTitle(markdown: string | undefined, fallback = "run"): string {
|
|
36
|
+
const text = normalize(markdown);
|
|
37
|
+
const line = text.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
|
|
38
|
+
return (line || fallback).slice(0, 250);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function extractSectionByHeading(markdown: string | undefined, heading: string): string {
|
|
42
|
+
const text = normalize(markdown);
|
|
43
|
+
if (text === "") return "";
|
|
44
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
const re = new RegExp(`^##\\s+${escaped}\\s*$`, "im");
|
|
46
|
+
const match = re.exec(text);
|
|
47
|
+
if (!match) return "";
|
|
48
|
+
const start = match.index + match[0].length;
|
|
49
|
+
const rest = text.slice(start);
|
|
50
|
+
const next = /^##\s+/m.exec(rest);
|
|
51
|
+
return rest.slice(0, next ? next.index : undefined).trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractTasksTable(tasks: string | undefined): string {
|
|
55
|
+
const text = normalize(tasks);
|
|
56
|
+
if (text === "") return "";
|
|
57
|
+
const taskMatches = [...text.matchAll(/^##\s+\[[ xX]\]\s+(T\d+\s+[^\n]*)/gm)];
|
|
58
|
+
if (taskMatches.length === 0) return "";
|
|
59
|
+
const rows = ["| Task | Files |", "| --- | --- |"];
|
|
60
|
+
for (const match of taskMatches) {
|
|
61
|
+
const title = match[1].trim();
|
|
62
|
+
const start = match.index ?? 0;
|
|
63
|
+
const next = text.slice(start + match[0].length).search(/^##\s+/m);
|
|
64
|
+
const block = next === -1 ? text.slice(start) : text.slice(start, start + match[0].length + next);
|
|
65
|
+
if (!/^\s*-\s+files:\s*$/m.test(block)) continue;
|
|
66
|
+
const files = [...block.matchAll(/^\s+-\s+`([^`]+)`/gm)].map((m) => m[1]);
|
|
67
|
+
if (files.length === 0) continue;
|
|
68
|
+
rows.push(`| ${title} | ${files.join("<br>")} |`);
|
|
69
|
+
}
|
|
70
|
+
return rows.length > 2 ? rows.join("\n") : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function extractAcceptanceCriteria(spec: string | undefined): string {
|
|
74
|
+
const text = normalize(spec);
|
|
75
|
+
if (text === "") return "";
|
|
76
|
+
const out: string[] = [];
|
|
77
|
+
const reqBlocks = text.split(/(?=^###\s+REQ:)/m).filter((b) => /^###\s+REQ:/m.test(b));
|
|
78
|
+
const blocks = reqBlocks.length ? reqBlocks : [text];
|
|
79
|
+
for (const block of blocks) {
|
|
80
|
+
const match = /Acceptance criteria:\s*\n([\s\S]*?)(?=\n#{2,3}\s+|\n\S(?![^\n]*\n\s*-)|$)/i.exec(block);
|
|
81
|
+
if (match?.[1]) {
|
|
82
|
+
const bullets = match[1].split("\n").map((l) => l.trim()).filter((l) => /^[-*]\s+/.test(l));
|
|
83
|
+
out.push(...bullets);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function issueSections(input: ArtifactTexts): string[] {
|
|
90
|
+
const proposal = proposalOf(input);
|
|
91
|
+
const spec = specOf(input);
|
|
92
|
+
const sections: string[] = [];
|
|
93
|
+
const contexto = extractFirstParagraph(proposal);
|
|
94
|
+
if (contexto) sections.push("## Contexto", contexto);
|
|
95
|
+
const alcance = extractSectionByHeading(proposal, "Alcance");
|
|
96
|
+
if (alcance) sections.push("## Alcance", alcance);
|
|
97
|
+
const criteria = extractAcceptanceCriteria(spec);
|
|
98
|
+
if (criteria) sections.push("## Criterios de aceptación", criteria);
|
|
99
|
+
return sections;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildPrBody(input: ArtifactTexts, links: { issueNumber?: number } = {}): BuiltBody {
|
|
103
|
+
const issueNumber = input.linkedIssueNumber ?? links.issueNumber;
|
|
104
|
+
const linkedIssue = issueNumber ? `Closes #${issueNumber}` : "Closes #\n<!-- Reemplazá # por el número de issue si existe. -->";
|
|
105
|
+
const body = [
|
|
106
|
+
"## 🔗 Linked Issue",
|
|
107
|
+
linkedIssue,
|
|
108
|
+
"",
|
|
109
|
+
"## 📝 Summary",
|
|
110
|
+
placeholder(extractFirstParagraph(proposalOf(input))),
|
|
111
|
+
"",
|
|
112
|
+
"## 📂 Changes",
|
|
113
|
+
placeholder(extractTasksTable(tasksOf(input))),
|
|
114
|
+
"",
|
|
115
|
+
"## 🧪 Test Plan",
|
|
116
|
+
placeholder(input.verdictReasoning ?? ""),
|
|
117
|
+
"",
|
|
118
|
+
"## ✅ Checklist",
|
|
119
|
+
"- [ ] Revisé el diff local",
|
|
120
|
+
"- [ ] Corrí los tests relevantes",
|
|
121
|
+
"- [ ] Actualicé artefactos SDD si correspondía",
|
|
122
|
+
].join("\n");
|
|
123
|
+
return { title: extractTitle(proposalOf(input)), body };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildIssueBody(input: ArtifactTexts): BuiltBody {
|
|
127
|
+
const body = issueSections(input).join("\n\n");
|
|
128
|
+
return { title: extractTitle(proposalOf(input)), body };
|
|
129
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface LinksRecord {
|
|
5
|
+
prNumber?: number;
|
|
6
|
+
prUrl?: string;
|
|
7
|
+
issueNumber?: number;
|
|
8
|
+
issueUrl?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function linksPath(sddDir: string, slug: string): string {
|
|
13
|
+
return join(sddDir, slug, "links.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validRecord(value: unknown): LinksRecord {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as LinksRecord : {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readLinks(sddDir: string, slug: string): LinksRecord {
|
|
21
|
+
const path = linksPath(sddDir, slug);
|
|
22
|
+
if (!existsSync(path)) return {};
|
|
23
|
+
try {
|
|
24
|
+
return validRecord(JSON.parse(readFileSync(path, "utf8")));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeLinks(sddDir: string, slug: string, partial: LinksRecord): LinksRecord {
|
|
31
|
+
const path = linksPath(sddDir, slug);
|
|
32
|
+
const next = { ...readLinks(sddDir, slug), ...partial };
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
35
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
@@ -114,6 +114,7 @@ function renderSyncReport(slug: string, date: string, summary: MergeSummary): st
|
|
|
114
114
|
"",
|
|
115
115
|
section("Added", summary.added),
|
|
116
116
|
section("Modified", summary.modified),
|
|
117
|
+
section("Renamed", summary.renamed.map((r) => `${r.from} -> ${r.to}`)),
|
|
117
118
|
section("Removed", summary.removed),
|
|
118
119
|
].join("\n");
|
|
119
120
|
}
|
|
@@ -125,6 +126,9 @@ function describeSummary(summary: MergeSummary): string {
|
|
|
125
126
|
if (summary.modified.length > 0) {
|
|
126
127
|
lines.push(` modificados: ${summary.modified.join(", ")} (reemplaza el texto de spec existente)`);
|
|
127
128
|
}
|
|
129
|
+
if (summary.renamed.length > 0) {
|
|
130
|
+
lines.push(` renombrados: ${summary.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")} (mantiene la posición en el store)`);
|
|
131
|
+
}
|
|
128
132
|
if (summary.removed.length > 0) {
|
|
129
133
|
lines.push(` eliminados: ${summary.removed.join(", ")} (borra del store)`);
|
|
130
134
|
}
|