@gonrocca/zero-pi 0.1.57 → 0.1.59
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 +18 -8
- package/extensions/autotune-extension.ts +40 -2
- package/extensions/autotune.ts +67 -0
- package/extensions/sdd-agents.ts +28 -0
- package/extensions/zero-checkpoint-extension.ts +62 -0
- package/extensions/zero-checkpoint.ts +92 -0
- package/extensions/zero-doctor-extension.ts +42 -0
- package/extensions/zero-doctor.ts +291 -0
- package/extensions/zero-models.ts +86 -0
- package/extensions/zero-validate.ts +23 -2
- package/package.json +7 -1
- package/prompts/orchestrator.md +25 -10
- package/prompts/phases/build.md +1 -2
- package/prompts/phases/plan.md +8 -0
package/README.md
CHANGED
|
@@ -74,9 +74,13 @@ into `/forge` for you.
|
|
|
74
74
|
| Feature | What it does |
|
|
75
75
|
| ------- | ------------ |
|
|
76
76
|
| **Strict TDD** | The build phase drives RED → GREEN → TRIANGULATE → REFACTOR with a TDD Cycle Evidence table; veredicto audits it. On by default, runtime-gated on a test runner; `tdd.mode: "off"` disables it. |
|
|
77
|
-
| **`/zero-models`** | Pick the model + provider + thinking level for each SDD phase — a boxed-window picker, or set one directly. |
|
|
78
|
-
| **
|
|
77
|
+
| **`/zero-models`** | Pick the model + provider + thinking level for each SDD phase — a boxed-window picker, or set one directly. Direct assignments are validated against pi's model registry when available. |
|
|
78
|
+
| **Phase tool gating** | Generated `zero-*` sub-agents get phase-specific tool allowlists: explore/veredicto are read-only, plan writes SDD artifacts, build edits code. |
|
|
79
|
+
| **Dependency-aware tasks** | `tasks.md` is validated as a task graph with mandatory `depends:` edges, topological ordering, and review workload totals. |
|
|
80
|
+
| **Autotune** | Learns which model fits each phase from your run history and re-tunes itself; optional `autotuneBudget.maxPhaseCostUsd` suppresses costly step-ups. |
|
|
81
|
+
| **`/zero-doctor`** | Preflight diagnostics for zero-pi: package install, Node version, pi-subagents, generated phase agents, model config, `.sdd/config`, run history, git, and `gh` auth. |
|
|
79
82
|
| **`/zero-cost`** | Aggregates a run's sub-agent `meta.json` files into a per-phase token/cost/duration report — no schema change, reads what pi already writes. |
|
|
83
|
+
| **`/zero-checkpoint`** | Writes patch-based worktree checkpoints under `.sdd/<slug>/checkpoints/` before risky build batches. |
|
|
80
84
|
| **`/zero-sync` / `/zero-archive`** | Folds each run's spec delta into a canonical, project-wide spec store and archives approved runs. |
|
|
81
85
|
| **Git / PR / Issues** | `/zero-branch`, `/zero-git-validate`, `/zero-pr`, and `/zero-issue` keep branches and GitHub links audit-ready. |
|
|
82
86
|
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
@@ -85,7 +89,7 @@ into `/forge` for you.
|
|
|
85
89
|
| **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
|
|
86
90
|
| **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
|
|
87
91
|
| **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
|
|
88
|
-
| **
|
|
92
|
+
| **SDD routing skill** | Natural-language requests that say "hacelo con sdd" route into `/forge` without remembering the slash command. |
|
|
89
93
|
| **`zero-sdd` theme** | A dark, high-contrast pi theme tuned for SDD work. |
|
|
90
94
|
| **`zero-sunset` theme** | A warm sunset variant — gold/coral/magenta accents over warm-dark panels, with one cool tone kept for syntax legibility. Activate with `/theme zero-sunset`. |
|
|
91
95
|
|
|
@@ -94,12 +98,14 @@ into `/forge` for you.
|
|
|
94
98
|
| Command | Does |
|
|
95
99
|
| ------- | ---- |
|
|
96
100
|
| `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
|
|
97
|
-
| `/zero-models [<phase>=[<provider>/]<model> [thinking=<level>]]` | Show or set per-phase models, providers, and thinking — `thinking=<level>` (`off\|minimal\|low\|medium\|high\|xhigh`); `autotune=auto\|ask\|off`. |
|
|
101
|
+
| `/zero-models [<phase>=[<provider>/]<model> [thinking=<level>]]` | Show or set per-phase models, providers, and thinking — `thinking=<level>` (`off\|minimal\|low\|medium\|high\|xhigh`); `autotune=auto\|ask\|off`. Direct assignments fail fast when pi's registry says the provider/model is unknown or ambiguous. |
|
|
102
|
+
| `/zero-doctor` | Run zero-pi preflight diagnostics: package, Node, pi-subagents, generated agents/support modules, model config, `.sdd/config`, run history, git, and `gh`. |
|
|
98
103
|
| `/zero-sync <slug>` | Fold a run's spec delta into the canonical spec store; a first all-`## ADDED` delta creates the store lazily. |
|
|
99
104
|
| `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
|
|
100
105
|
| `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
|
|
101
106
|
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
102
107
|
| `/zero-cost [<slug>]` | Report tokens (in/out/cache), USD cost, duration, and tool-count per SDD phase for a run — `<slug>` for a specific run, no argument for the most recent. |
|
|
108
|
+
| `/zero-checkpoint [<slug>] [--json]` | Save `diff.patch`, `status.txt`, `head.txt`, `meta.json`, and a review-before-running `restore.sh` under `.sdd/<slug>/checkpoints/<id>/`. |
|
|
103
109
|
| `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
|
|
104
110
|
| `/zero-git-validate <slug>` | Check worktree, branch, remote, `gh auth`, and verdict gating before PR/archive. |
|
|
105
111
|
| `/zero-pr <slug>` | Create an audit-ready GitHub PR from a run that already has verdict `pasa`. |
|
|
@@ -109,7 +115,7 @@ into `/forge` for you.
|
|
|
109
115
|
|
|
110
116
|
### Git / PR / Issues
|
|
111
117
|
|
|
112
|
-
Recommended flow: `/zero-branch <slug>` → `/zero-issue <slug>` → `/forge <slug>` → `/zero-git-validate <slug> --for=pr` → `/zero-pr <slug>` → `/zero-archive <slug>`.
|
|
118
|
+
Recommended flow: `/zero-branch <slug>` → `/zero-issue <slug>` → `/forge <slug>` (the orchestrator validates plan artifacts and can create `/zero-checkpoint` before build) → `/zero-git-validate <slug> --for=pr` → `/zero-pr <slug>` → `/zero-archive <slug>`.
|
|
113
119
|
|
|
114
120
|
`links.json` is forward-compatible and may contain:
|
|
115
121
|
|
|
@@ -180,14 +186,18 @@ conversation-resume note.
|
|
|
180
186
|
"models": { "explore": "claude-haiku-4-5", "build": "claude-sonnet-4-6" },
|
|
181
187
|
"providers": { "explore": "anthropic", "build": "anthropic" },
|
|
182
188
|
"thinking": { "explore": "low", "build": "high" },
|
|
183
|
-
"autotune": "ask"
|
|
189
|
+
"autotune": "ask",
|
|
190
|
+
"autotuneBudget": { "maxPhaseCostUsd": 4, "minSamples": 3 }
|
|
184
191
|
}
|
|
185
192
|
```
|
|
186
193
|
|
|
187
194
|
The `thinking` map sets each phase's pi effort level — one of `off`, `minimal`,
|
|
188
195
|
`low`, `medium`, `high`, `xhigh`. A phase with no entry gets no `thinking:` line
|
|
189
|
-
in its generated sub-agent (no aggressive default).
|
|
190
|
-
|
|
196
|
+
in its generated sub-agent (no aggressive default). `autotuneBudget` is optional:
|
|
197
|
+
when `maxPhaseCostUsd` is set, autotune will not step a blamed phase up to a more
|
|
198
|
+
expensive tier if that phase/model is already at or above the average USD
|
|
199
|
+
ceiling with enough cost samples (`minSamples`, default 3). Set thinking from
|
|
200
|
+
the picker's after-model thinking screen, or directly:
|
|
191
201
|
|
|
192
202
|
```
|
|
193
203
|
/zero-models build=anthropic/claude-sonnet-4-6 thinking=high
|
|
@@ -19,17 +19,20 @@
|
|
|
19
19
|
// dependency-free: `node:fs`/`node:os`/`node:path`
|
|
20
20
|
// only, plus minimal local interfaces for the pi API.
|
|
21
21
|
|
|
22
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
aggregate,
|
|
28
|
+
aggregateCostStats,
|
|
28
29
|
decideAdjustments,
|
|
29
30
|
readAutotuneMode,
|
|
30
31
|
readRunRecords,
|
|
31
32
|
type Adjustment,
|
|
33
|
+
type AutotuneCostGuard,
|
|
32
34
|
} from "./autotune.ts";
|
|
35
|
+
import { parseMeta, type PhaseMeta } from "./zero-cost.ts";
|
|
33
36
|
|
|
34
37
|
/** The SDD phases, in pipeline order. Mirrors `zero-models.ts`. */
|
|
35
38
|
const PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
@@ -104,6 +107,39 @@ function readCurrentModels(data: Record<string, unknown>): Partial<Record<Phase,
|
|
|
104
107
|
return models;
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
function readCostGuard(data: Record<string, unknown>): AutotuneCostGuard {
|
|
111
|
+
const raw = isObject(data.autotuneBudget) ? data.autotuneBudget : {};
|
|
112
|
+
const guard: AutotuneCostGuard = {};
|
|
113
|
+
if (typeof raw.maxPhaseCostUsd === "number" && Number.isFinite(raw.maxPhaseCostUsd) && raw.maxPhaseCostUsd > 0) {
|
|
114
|
+
guard.maxPhaseCostUsd = raw.maxPhaseCostUsd;
|
|
115
|
+
}
|
|
116
|
+
if (typeof raw.minSamples === "number" && Number.isFinite(raw.minSamples) && raw.minSamples > 0) {
|
|
117
|
+
guard.minSamples = raw.minSamples;
|
|
118
|
+
}
|
|
119
|
+
return guard;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readAllPhaseMetas(): PhaseMeta[] {
|
|
123
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
124
|
+
if (!existsSync(root)) return [];
|
|
125
|
+
const out: PhaseMeta[] = [];
|
|
126
|
+
for (const session of readdirSync(root, { withFileTypes: true })) {
|
|
127
|
+
if (!session.isDirectory()) continue;
|
|
128
|
+
const artifacts = join(root, session.name, "subagent-artifacts");
|
|
129
|
+
if (!existsSync(artifacts)) continue;
|
|
130
|
+
for (const f of readdirSync(artifacts)) {
|
|
131
|
+
if (!f.endsWith("_meta.json")) continue;
|
|
132
|
+
try {
|
|
133
|
+
const meta = parseMeta(JSON.parse(readFileSync(join(artifacts, f), "utf8")));
|
|
134
|
+
if (meta) out.push(meta);
|
|
135
|
+
} catch {
|
|
136
|
+
// skip unreadable / malformed meta
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
/**
|
|
108
144
|
* Derive `knownModels` — the distinct, non-empty model ids the autotune logic
|
|
109
145
|
* may step toward. Per the design this is the union of every model seen in the
|
|
@@ -177,7 +213,9 @@ function evaluateAndTune(ctx: PiSessionContext): void {
|
|
|
177
213
|
const knownModels = deriveKnownModels(currentModels, loggedModels);
|
|
178
214
|
|
|
179
215
|
const stats = aggregate(records);
|
|
180
|
-
const
|
|
216
|
+
const costGuard = readCostGuard(data);
|
|
217
|
+
const costStats = costGuard.maxPhaseCostUsd ? aggregateCostStats(readAllPhaseMetas()) : new Map();
|
|
218
|
+
const adjustments: Adjustment[] = decideAdjustments(stats, currentModels, knownModels, costStats, costGuard);
|
|
181
219
|
if (adjustments.length === 0) return; // nothing to do — return silently.
|
|
182
220
|
|
|
183
221
|
if (mode === "auto") {
|
package/extensions/autotune.ts
CHANGED
|
@@ -265,6 +265,29 @@ export interface PhaseModelStat {
|
|
|
265
265
|
avgReplantear: number | null;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
export interface PhaseCostSample {
|
|
269
|
+
phase: unknown;
|
|
270
|
+
model: unknown;
|
|
271
|
+
usage: unknown;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface PhaseCostStat {
|
|
275
|
+
phase: (typeof RECORD_PHASES)[number];
|
|
276
|
+
model: string;
|
|
277
|
+
samples: number;
|
|
278
|
+
totalCost: number;
|
|
279
|
+
avgCost: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface AutotuneCostGuard {
|
|
283
|
+
/** Optional per-phase average cost ceiling in USD. Absent/invalid disables the guard. */
|
|
284
|
+
maxPhaseCostUsd?: number;
|
|
285
|
+
/** Minimum cost samples before the ceiling can suppress a step-up. Default: MIN_COST_SAMPLES. */
|
|
286
|
+
minSamples?: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const MIN_COST_SAMPLES = 3;
|
|
290
|
+
|
|
268
291
|
/** Map key for a `(phase, model)` bucket. */
|
|
269
292
|
function statKey(phase: string, model: string): string {
|
|
270
293
|
return `${phase} ${model}`;
|
|
@@ -287,6 +310,38 @@ function statKey(phase: string, model: string): string {
|
|
|
287
310
|
* `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
|
|
288
311
|
* pair has no v2 evidence. An empty input yields an empty map.
|
|
289
312
|
*/
|
|
313
|
+
export function aggregateCostStats(samples: readonly PhaseCostSample[]): Map<string, PhaseCostStat> {
|
|
314
|
+
const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; samples: number; totalCost: number }>();
|
|
315
|
+
for (const sample of samples) {
|
|
316
|
+
const phase = sample.phase;
|
|
317
|
+
if (typeof phase !== "string" || !(RECORD_PHASES as readonly string[]).includes(phase)) continue;
|
|
318
|
+
const model = sample.model;
|
|
319
|
+
if (typeof model !== "string" || model === "") continue;
|
|
320
|
+
const usage = isObject(sample.usage) ? sample.usage : {};
|
|
321
|
+
const cost = usage.cost;
|
|
322
|
+
if (typeof cost !== "number" || !Number.isFinite(cost)) continue;
|
|
323
|
+
const key = statKey(phase, model);
|
|
324
|
+
let bucket = acc.get(key);
|
|
325
|
+
if (!bucket) {
|
|
326
|
+
bucket = { phase: phase as (typeof RECORD_PHASES)[number], model, samples: 0, totalCost: 0 };
|
|
327
|
+
acc.set(key, bucket);
|
|
328
|
+
}
|
|
329
|
+
bucket.samples += 1;
|
|
330
|
+
bucket.totalCost += cost;
|
|
331
|
+
}
|
|
332
|
+
const stats = new Map<string, PhaseCostStat>();
|
|
333
|
+
for (const [key, bucket] of acc) {
|
|
334
|
+
stats.set(key, {
|
|
335
|
+
phase: bucket.phase,
|
|
336
|
+
model: bucket.model,
|
|
337
|
+
samples: bucket.samples,
|
|
338
|
+
totalCost: bucket.totalCost,
|
|
339
|
+
avgCost: bucket.samples > 0 ? bucket.totalCost / bucket.samples : 0,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return stats;
|
|
343
|
+
}
|
|
344
|
+
|
|
290
345
|
export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
291
346
|
interface Acc {
|
|
292
347
|
samples: number;
|
|
@@ -505,6 +560,8 @@ export function decideAdjustments(
|
|
|
505
560
|
stats: Map<string, PhaseModelStat>,
|
|
506
561
|
currentModels: Partial<Record<(typeof RECORD_PHASES)[number], string>>,
|
|
507
562
|
knownModels: readonly string[],
|
|
563
|
+
costStats: Map<string, PhaseCostStat> = new Map(),
|
|
564
|
+
costGuard: AutotuneCostGuard = {},
|
|
508
565
|
): Adjustment[] {
|
|
509
566
|
const adjustments: Adjustment[] = [];
|
|
510
567
|
|
|
@@ -522,6 +579,16 @@ export function decideAdjustments(
|
|
|
522
579
|
const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
|
|
523
580
|
if (measure === null || !(measure > threshold)) continue;
|
|
524
581
|
|
|
582
|
+
const maxPhaseCostUsd = costGuard.maxPhaseCostUsd;
|
|
583
|
+
if (typeof maxPhaseCostUsd === "number" && Number.isFinite(maxPhaseCostUsd) && maxPhaseCostUsd > 0) {
|
|
584
|
+
const minCostSamples =
|
|
585
|
+
typeof costGuard.minSamples === "number" && Number.isFinite(costGuard.minSamples) && costGuard.minSamples > 0
|
|
586
|
+
? Math.floor(costGuard.minSamples)
|
|
587
|
+
: MIN_COST_SAMPLES;
|
|
588
|
+
const cost = costStats.get(statKey(phase, currentModel));
|
|
589
|
+
if (cost && cost.samples >= minCostSamples && cost.avgCost >= maxPhaseCostUsd) continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
525
592
|
const to = stepUp(currentModel, knownModels);
|
|
526
593
|
if (to === null) continue; // already at top tier, or untierable
|
|
527
594
|
|
package/extensions/sdd-agents.ts
CHANGED
|
@@ -32,6 +32,32 @@ export type Phase = (typeof PHASES)[number];
|
|
|
32
32
|
*/
|
|
33
33
|
export const SUPPORT_MODULES = ["strict-tdd.md", "strict-tdd-verify.md"] as const;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Phase-specific builtin tool allowlists for generated pi-subagents agents.
|
|
37
|
+
*
|
|
38
|
+
* Explore and veredicto are intentionally read-only at the mutation-tool layer:
|
|
39
|
+
* they can inspect files and run scoped verification commands, but they cannot
|
|
40
|
+
* edit. Plan can write only SDD artifacts; build is the only phase with the
|
|
41
|
+
* full mutation set for product code. This is enforced by pi-subagents' `tools:`
|
|
42
|
+
* frontmatter key, so an accidental phase prompt drift does not silently grant
|
|
43
|
+
* broad editing power to every child.
|
|
44
|
+
*/
|
|
45
|
+
export const PHASE_TOOLS: Record<Phase, readonly string[]> = {
|
|
46
|
+
explore: ["read", "bash"],
|
|
47
|
+
plan: ["read", "bash", "write", "edit"],
|
|
48
|
+
build: ["read", "bash", "write", "edit"],
|
|
49
|
+
veredicto: ["read", "bash"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Non-build phases may mention implementation terms while using `bash`; do not
|
|
53
|
+
* let pi-subagents' implementation completion guard classify them as writers. */
|
|
54
|
+
export const PHASE_COMPLETION_GUARD: Record<Phase, boolean> = {
|
|
55
|
+
explore: false,
|
|
56
|
+
plan: false,
|
|
57
|
+
build: true,
|
|
58
|
+
veredicto: false,
|
|
59
|
+
};
|
|
60
|
+
|
|
35
61
|
/** Absolute path of the runtime support dir the phase prompts reference. */
|
|
36
62
|
export function supportModulesDir(): string {
|
|
37
63
|
return join(homedir(), ".pi", "agent", "agents", "zero", "support");
|
|
@@ -75,6 +101,8 @@ export function buildAgentFile(
|
|
|
75
101
|
// defensive: callers already validate, but the file builder must never write
|
|
76
102
|
// a bad level into agent frontmatter.
|
|
77
103
|
if (thinking && isThinkingLevel(thinking)) front.push(`thinking: ${thinking}`);
|
|
104
|
+
front.push(`tools: ${PHASE_TOOLS[phase].join(", ")}`);
|
|
105
|
+
if (!PHASE_COMPLETION_GUARD[phase]) front.push("completionGuard: false");
|
|
78
106
|
front.push(
|
|
79
107
|
"systemPromptMode: replace",
|
|
80
108
|
"inheritProjectContext: true",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
import { createCheckpoint, formatCheckpointReport, parseCheckpointArgs, type CheckpointRunner } from "./zero-checkpoint.ts";
|
|
6
|
+
|
|
7
|
+
const SDD_DIR = ".sdd";
|
|
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 resolveSlug(explicit: string | null): string | null {
|
|
14
|
+
if (explicit) return explicit;
|
|
15
|
+
try {
|
|
16
|
+
const candidates = readdirSync(SDD_DIR, { withFileTypes: true })
|
|
17
|
+
.filter((e) => e.isDirectory() && e.name !== "specs" && e.name !== "archive" && existsSync(join(SDD_DIR, e.name)))
|
|
18
|
+
.map((e) => e.name);
|
|
19
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultRunner(cwd: string): CheckpointRunner {
|
|
26
|
+
return {
|
|
27
|
+
run(command, args) {
|
|
28
|
+
try {
|
|
29
|
+
const r = spawnSync(command, [...args], { cwd, encoding: "utf8" });
|
|
30
|
+
return {
|
|
31
|
+
status: typeof r.status === "number" ? r.status : 1,
|
|
32
|
+
stdout: r.stdout ?? "",
|
|
33
|
+
stderr: r.stderr ?? "",
|
|
34
|
+
error: r.error?.message,
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { status: 1, stdout: "", stderr: "", error: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runCheckpoint(args: string, ctx: PiCommandContext, runner = defaultRunner(process.cwd())): void {
|
|
44
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
45
|
+
const parsed = parseCheckpointArgs(args ?? "");
|
|
46
|
+
const slug = resolveSlug(parsed.slug);
|
|
47
|
+
if (!slug) { notify("zero-checkpoint: no hay un único run — corré /zero-checkpoint <slug>", "warning"); return; }
|
|
48
|
+
const result = createCheckpoint(slug, process.cwd(), runner);
|
|
49
|
+
if (parsed.json) notify(JSON.stringify(result, null, 2), result.ok ? "info" : "error");
|
|
50
|
+
else notify(formatCheckpointReport(result), result.ok ? "info" : "error");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
54
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
55
|
+
pi.registerCommand("zero-checkpoint", {
|
|
56
|
+
description: "Crea un checkpoint patch-based del worktree actual dentro de .sdd/<slug>/checkpoints/",
|
|
57
|
+
handler: (args: string, ctx: PiCommandContext): void => {
|
|
58
|
+
try { if (ctx?.ui?.notify) runCheckpoint(args ?? "", ctx); }
|
|
59
|
+
catch (err) { try { ctx.ui.notify(`zero-checkpoint: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CheckpointRunner {
|
|
5
|
+
run(command: string, args: readonly string[]): { status: number; stdout: string; stderr: string; error?: string };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CheckpointResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
slug: string;
|
|
11
|
+
id: string;
|
|
12
|
+
dir: string;
|
|
13
|
+
head: string;
|
|
14
|
+
dirty: boolean;
|
|
15
|
+
untracked: string[];
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkpointId(date = new Date()): string {
|
|
20
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
21
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseCheckpointArgs(args: string): { slug: string | null; json: boolean } {
|
|
25
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
26
|
+
let slug: string | null = null;
|
|
27
|
+
let json = false;
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
if (part === "--json") json = true;
|
|
30
|
+
else if (!slug) slug = part;
|
|
31
|
+
}
|
|
32
|
+
return { slug, json };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function untrackedFromStatus(status: string): string[] {
|
|
36
|
+
return status
|
|
37
|
+
.split(/\r?\n/)
|
|
38
|
+
.filter((line) => line.startsWith("?? "))
|
|
39
|
+
.map((line) => line.slice(3).trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatCheckpointReport(result: CheckpointResult): string {
|
|
44
|
+
if (!result.ok) return `zero-checkpoint: ${result.message ?? "falló"}`;
|
|
45
|
+
const lines = [
|
|
46
|
+
`zero-checkpoint: ${result.slug} @ ${result.id}`,
|
|
47
|
+
` dir: ${result.dir}`,
|
|
48
|
+
` head: ${result.head}`,
|
|
49
|
+
result.dirty ? " estado: cambios capturados en diff.patch" : " estado: worktree limpio (checkpoint base)",
|
|
50
|
+
];
|
|
51
|
+
if (result.untracked.length > 0) {
|
|
52
|
+
lines.push(` aviso: ${result.untracked.length} untracked no se incluyen en diff.patch (${result.untracked.slice(0, 5).join(", ")}${result.untracked.length > 5 ? ", …" : ""})`);
|
|
53
|
+
}
|
|
54
|
+
lines.push(" restore: revisá restore.sh antes de ejecutarlo");
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createCheckpoint(
|
|
59
|
+
slug: string,
|
|
60
|
+
cwd: string,
|
|
61
|
+
runner: CheckpointRunner,
|
|
62
|
+
now = new Date(),
|
|
63
|
+
): CheckpointResult {
|
|
64
|
+
const root = runner.run("git", ["rev-parse", "--show-toplevel"]);
|
|
65
|
+
if (root.status !== 0) return { ok: false, slug, id: "", dir: "", head: "", dirty: false, untracked: [], message: "este cwd no parece estar dentro de un repo git" };
|
|
66
|
+
const repoRoot = root.stdout.trim() || cwd;
|
|
67
|
+
|
|
68
|
+
const head = runner.run("git", ["rev-parse", "--short", "HEAD"]);
|
|
69
|
+
if (head.status !== 0) return { ok: false, slug, id: "", dir: "", head: "", dirty: false, untracked: [], message: "no pude resolver HEAD" };
|
|
70
|
+
|
|
71
|
+
const status = runner.run("git", ["status", "--short"]);
|
|
72
|
+
const diff = runner.run("git", ["diff", "--binary", "HEAD"]);
|
|
73
|
+
if (status.status !== 0 || diff.status !== 0) return { ok: false, slug, id: "", dir: "", head: head.stdout.trim(), dirty: false, untracked: [], message: "no pude leer el estado git" };
|
|
74
|
+
|
|
75
|
+
const id = checkpointId(now);
|
|
76
|
+
const dir = join(repoRoot, ".sdd", slug, "checkpoints", id);
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
const untracked = untrackedFromStatus(status.stdout);
|
|
79
|
+
const dirty = status.stdout.trim() !== "";
|
|
80
|
+
|
|
81
|
+
writeFileSync(join(dir, "head.txt"), `${head.stdout.trim()}\n`, "utf8");
|
|
82
|
+
writeFileSync(join(dir, "status.txt"), status.stdout, "utf8");
|
|
83
|
+
writeFileSync(join(dir, "diff.patch"), diff.stdout, "utf8");
|
|
84
|
+
writeFileSync(join(dir, "meta.json"), `${JSON.stringify({ slug, id, head: head.stdout.trim(), dirty, untracked, createdAt: now.toISOString() }, null, 2)}\n`, "utf8");
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(dir, "restore.sh"),
|
|
87
|
+
`#!/usr/bin/env bash\nset -euo pipefail\n# Review before running. This restores tracked files to checkpoint ${id}.\ngit reset --hard ${head.stdout.trim()}\ngit apply --3way ${JSON.stringify(join(dir, "diff.patch"))}\n`,
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { ok: true, slug, id, dir, head: head.stdout.trim(), dirty, untracked };
|
|
92
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createDefaultDoctorHost, formatDoctorReport, runDoctor } from "./zero-doctor.ts";
|
|
2
|
+
import type { PiModel } from "./zero-models.ts";
|
|
3
|
+
|
|
4
|
+
type NotifyType = "info" | "warning" | "error";
|
|
5
|
+
|
|
6
|
+
interface PiCommandContext {
|
|
7
|
+
ui: { notify(message: string, type?: NotifyType): void };
|
|
8
|
+
modelRegistry?: { getAll(): PiModel[] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PiExtensionAPI {
|
|
12
|
+
registerCommand(
|
|
13
|
+
name: string,
|
|
14
|
+
options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> },
|
|
15
|
+
): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function registryModels(ctx: PiCommandContext): PiModel[] | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const models = ctx.modelRegistry?.getAll?.();
|
|
21
|
+
return Array.isArray(models) ? models : undefined;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
28
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
29
|
+
pi.registerCommand("zero-doctor", {
|
|
30
|
+
description: "Diagnostica instalación, modelos, sub-agentes, git/gh y estado SDD de zero-pi",
|
|
31
|
+
handler: (_args: string, ctx: PiCommandContext): void => {
|
|
32
|
+
try {
|
|
33
|
+
const report = runDoctor(createDefaultDoctorHost(registryModels(ctx)));
|
|
34
|
+
ctx.ui.notify(formatDoctorReport(report), report.ok ? "info" : "error");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
try {
|
|
37
|
+
ctx.ui.notify(`zero-doctor: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
groupByProvider,
|
|
9
|
+
PHASES,
|
|
10
|
+
readModels,
|
|
11
|
+
readProviders,
|
|
12
|
+
validateAssignment,
|
|
13
|
+
type PiModel,
|
|
14
|
+
} from "./zero-models.ts";
|
|
15
|
+
|
|
16
|
+
export type DoctorLevel = "ok" | "warn" | "fail";
|
|
17
|
+
|
|
18
|
+
export interface DoctorCheck {
|
|
19
|
+
name: string;
|
|
20
|
+
level: DoctorLevel;
|
|
21
|
+
message: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DoctorReport {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
checks: DoctorCheck[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommandResult {
|
|
31
|
+
status: number | null;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DoctorHost {
|
|
38
|
+
cwd: string;
|
|
39
|
+
home: string;
|
|
40
|
+
nodeVersion: string;
|
|
41
|
+
models?: readonly PiModel[];
|
|
42
|
+
exists(path: string): boolean;
|
|
43
|
+
readText(path: string): string | null;
|
|
44
|
+
listDir(path: string): string[];
|
|
45
|
+
run(command: string, args: readonly string[], cwd: string): CommandResult;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function currentPackageDir(): string {
|
|
49
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readText(path: string): string | null {
|
|
53
|
+
try {
|
|
54
|
+
return readFileSync(path, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultRun(command: string, args: readonly string[], cwd: string): CommandResult {
|
|
61
|
+
try {
|
|
62
|
+
const out = spawnSync(command, [...args], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
status: out.status,
|
|
70
|
+
stdout: typeof out.stdout === "string" ? out.stdout : "",
|
|
71
|
+
stderr: typeof out.stderr === "string" ? out.stderr : "",
|
|
72
|
+
error: out.error?.message,
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { status: null, stdout: "", stderr: "", error: err instanceof Error ? err.message : String(err) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createDefaultDoctorHost(models?: readonly PiModel[]): DoctorHost {
|
|
80
|
+
return {
|
|
81
|
+
cwd: process.cwd(),
|
|
82
|
+
home: homedir(),
|
|
83
|
+
nodeVersion: process.version,
|
|
84
|
+
models,
|
|
85
|
+
exists: existsSync,
|
|
86
|
+
readText,
|
|
87
|
+
listDir(path: string): string[] {
|
|
88
|
+
try {
|
|
89
|
+
return readdirSync(path);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
run: defaultRun,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function check(name: string, level: DoctorLevel, message: string, hint?: string): DoctorCheck {
|
|
99
|
+
return hint ? { name, level, message, hint } : { name, level, message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseVersion(version: string): number[] {
|
|
103
|
+
return version.replace(/^v/, "").split(".").map((p) => Number.parseInt(p, 10)).map((n) => (Number.isFinite(n) ? n : 0));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function versionAtLeast(version: string, minimum: string): boolean {
|
|
107
|
+
const a = parseVersion(version);
|
|
108
|
+
const b = parseVersion(minimum);
|
|
109
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
110
|
+
const av = a[i] ?? 0;
|
|
111
|
+
const bv = b[i] ?? 0;
|
|
112
|
+
if (av > bv) return true;
|
|
113
|
+
if (av < bv) return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseJson(text: string | null): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } | null {
|
|
119
|
+
if (text === null) return null;
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(text);
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
123
|
+
? { ok: true, value: value as Record<string, unknown> }
|
|
124
|
+
: { ok: false, error: "JSON root is not an object" };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function settingsPackages(settings: Record<string, unknown>): string[] {
|
|
131
|
+
const raw = settings.packages;
|
|
132
|
+
if (!Array.isArray(raw)) return [];
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
for (const entry of raw) {
|
|
135
|
+
if (typeof entry === "string") out.push(entry);
|
|
136
|
+
else if (entry && typeof entry === "object" && typeof (entry as { source?: unknown }).source === "string") out.push((entry as { source: string }).source);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function packageSourceName(source: string): string {
|
|
142
|
+
return source.replace(/^npm:/, "").replace(/@[^/@]+$/, "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasPackageSource(packages: readonly string[], name: string): boolean {
|
|
146
|
+
return packages.some((p) => packageSourceName(p) === name || packageSourceName(p).endsWith(`/${name}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkNode(host: DoctorHost): DoctorCheck {
|
|
150
|
+
return versionAtLeast(host.nodeVersion, "20.6.0")
|
|
151
|
+
? check("node", "ok", `${host.nodeVersion} (>=20.6.0)`)
|
|
152
|
+
: check("node", "fail", `${host.nodeVersion} es viejo`, "zero-pi requiere Node >=20.6.0");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function checkPiSubagents(host: DoctorHost, settings: Record<string, unknown> | null): DoctorCheck {
|
|
156
|
+
const installed = host.exists(join(host.home, ".pi", "agent", "npm", "node_modules", "pi-subagents", "package.json"));
|
|
157
|
+
const configured = settings ? hasPackageSource(settingsPackages(settings), "pi-subagents") : false;
|
|
158
|
+
if (installed || configured) return check("pi-subagents", "ok", installed ? "package instalado" : "declarado en settings");
|
|
159
|
+
return check(
|
|
160
|
+
"pi-subagents",
|
|
161
|
+
"fail",
|
|
162
|
+
"no lo encontré instalado ni declarado",
|
|
163
|
+
"corré: pi install npm:pi-subagents",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkZeroJson(host: DoctorHost): { check: DoctorCheck; data: Record<string, unknown> | null } {
|
|
168
|
+
const path = join(host.home, ".pi", "zero.json");
|
|
169
|
+
const parsed = parseJson(host.readText(path));
|
|
170
|
+
if (parsed === null) return { check: check("zero.json", "warn", "~/.pi/zero.json no existe", "se crea al usar /zero-models"), data: null };
|
|
171
|
+
if (!parsed.ok) return { check: check("zero.json", "fail", `JSON inválido: ${parsed.error}`), data: null };
|
|
172
|
+
|
|
173
|
+
const groups = groupByProvider(host.models ?? []);
|
|
174
|
+
const models = readModels(parsed.value);
|
|
175
|
+
const providers = readProviders(parsed.value);
|
|
176
|
+
const failures: string[] = [];
|
|
177
|
+
if (groups.size > 0) {
|
|
178
|
+
for (const phase of PHASES) {
|
|
179
|
+
const assignment = { phase, model: models[phase], provider: providers[phase] || undefined };
|
|
180
|
+
const validation = validateAssignment(assignment, groups);
|
|
181
|
+
if (!validation.ok) failures.push(`${phase}: ${validation.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (failures.length > 0) return { check: check("zero.json", "fail", failures.join(" · ")), data: parsed.value };
|
|
185
|
+
return { check: check("zero.json", "ok", groups.size > 0 ? "parseable y modelos válidos" : "parseable (sin registry para validar modelos)"), data: parsed.value };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkSettings(host: DoctorHost): { check: DoctorCheck; data: Record<string, unknown> | null } {
|
|
189
|
+
const path = join(host.home, ".pi", "agent", "settings.json");
|
|
190
|
+
const parsed = parseJson(host.readText(path));
|
|
191
|
+
if (parsed === null) return { check: check("settings", "warn", "~/.pi/agent/settings.json no existe"), data: null };
|
|
192
|
+
if (!parsed.ok) return { check: check("settings", "fail", `JSON inválido: ${parsed.error}`), data: null };
|
|
193
|
+
return { check: check("settings", "ok", "parseable"), data: parsed.value };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkGeneratedAgents(host: DoctorHost): DoctorCheck {
|
|
197
|
+
const dir = join(host.home, ".pi", "agent", "agents", "zero");
|
|
198
|
+
const missing = PHASES.map((p) => `zero-${p}.md`).filter((file) => !host.exists(join(dir, file)));
|
|
199
|
+
if (missing.length === 0) return check("agents", "ok", "sub-agentes zero generados");
|
|
200
|
+
return check("agents", "warn", `faltan ${missing.join(", ")}`, "reiniciá pi para que zero-pi regenere ~/.pi/agent/agents/zero/");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkSupportModules(host: DoctorHost): DoctorCheck {
|
|
204
|
+
const dir = join(host.home, ".pi", "agent", "agents", "zero", "support");
|
|
205
|
+
const required = ["strict-tdd.md", "strict-tdd-verify.md"];
|
|
206
|
+
const missing = required.filter((file) => !host.exists(join(dir, file)));
|
|
207
|
+
if (missing.length === 0) return check("support", "ok", "Strict TDD support modules presentes");
|
|
208
|
+
return check("support", "warn", `faltan ${missing.join(", ")}`, "reiniciá pi o reinstalá zero-pi");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkSddConfig(host: DoctorHost): DoctorCheck {
|
|
212
|
+
const path = join(host.cwd, ".sdd", "config.json");
|
|
213
|
+
const parsed = parseJson(host.readText(path));
|
|
214
|
+
if (parsed === null) return check(".sdd/config", "ok", "no existe (opcional)");
|
|
215
|
+
if (!parsed.ok) return check(".sdd/config", "fail", `JSON inválido: ${parsed.error}`);
|
|
216
|
+
return check(".sdd/config", "ok", "parseable");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkZeroRuns(host: DoctorHost): DoctorCheck {
|
|
220
|
+
const path = join(host.home, ".pi", "zero-runs.jsonl");
|
|
221
|
+
const text = host.readText(path);
|
|
222
|
+
if (text === null) return check("zero-runs", "warn", "~/.pi/zero-runs.jsonl no existe todavía", "se crea después de runs /forge");
|
|
223
|
+
let lines = 0;
|
|
224
|
+
let bad = 0;
|
|
225
|
+
for (const line of text.split(/\r?\n/)) {
|
|
226
|
+
if (!line.trim()) continue;
|
|
227
|
+
lines += 1;
|
|
228
|
+
try { JSON.parse(line); } catch { bad += 1; }
|
|
229
|
+
}
|
|
230
|
+
if (bad > 0) return check("zero-runs", "warn", `${bad}/${lines} líneas no parsean como JSONL`);
|
|
231
|
+
return check("zero-runs", "ok", `${lines} records parseables`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function checkGit(host: DoctorHost): DoctorCheck {
|
|
235
|
+
const root = host.run("git", ["rev-parse", "--show-toplevel"], host.cwd);
|
|
236
|
+
if (root.status !== 0) return check("git", "warn", "este cwd no parece estar dentro de un repo git", root.stderr.trim() || root.error);
|
|
237
|
+
const repoRoot = root.stdout.trim() || host.cwd;
|
|
238
|
+
const remote = host.run("git", ["remote", "get-url", "origin"], repoRoot);
|
|
239
|
+
if (remote.status === 0) return check("git", "ok", `origin: ${remote.stdout.trim()}`);
|
|
240
|
+
return check("git", "warn", "repo git sin remote origin", remote.stderr.trim() || remote.error);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkGh(host: DoctorHost): DoctorCheck {
|
|
244
|
+
const gh = host.run("gh", ["auth", "status"], host.cwd);
|
|
245
|
+
if (gh.status === 0) return check("gh", "ok", "GitHub CLI autenticado");
|
|
246
|
+
const reason = (gh.stderr || gh.stdout || gh.error || "gh auth status falló").trim().split("\n")[0];
|
|
247
|
+
return check("gh", "warn", reason || "gh auth status falló", "necesario para /zero-pr y /zero-issue");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function checkPackageManifest(host: DoctorHost): DoctorCheck {
|
|
251
|
+
const pkg = parseJson(host.readText(join(currentPackageDir(), "package.json")));
|
|
252
|
+
if (!pkg || !pkg.ok) return check("zero-pi package", "warn", "no pude leer package.json del paquete");
|
|
253
|
+
const version = typeof pkg.value.version === "string" ? pkg.value.version : "unknown";
|
|
254
|
+
return check("zero-pi package", "ok", `@gonrocca/zero-pi v${version}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function runDoctor(host: DoctorHost = createDefaultDoctorHost()): DoctorReport {
|
|
258
|
+
const checks: DoctorCheck[] = [];
|
|
259
|
+
checks.push(checkPackageManifest(host));
|
|
260
|
+
checks.push(checkNode(host));
|
|
261
|
+
const settings = checkSettings(host);
|
|
262
|
+
checks.push(settings.check);
|
|
263
|
+
checks.push(checkPiSubagents(host, settings.data));
|
|
264
|
+
const zero = checkZeroJson(host);
|
|
265
|
+
checks.push(zero.check);
|
|
266
|
+
checks.push(checkGeneratedAgents(host));
|
|
267
|
+
checks.push(checkSupportModules(host));
|
|
268
|
+
checks.push(checkSddConfig(host));
|
|
269
|
+
checks.push(checkZeroRuns(host));
|
|
270
|
+
checks.push(checkGit(host));
|
|
271
|
+
checks.push(checkGh(host));
|
|
272
|
+
return { ok: checks.every((c) => c.level !== "fail"), checks };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function icon(level: DoctorLevel): string {
|
|
276
|
+
if (level === "ok") return "✅";
|
|
277
|
+
if (level === "warn") return "⚠️";
|
|
278
|
+
return "❌";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatDoctorReport(report: DoctorReport): string {
|
|
282
|
+
const ok = report.checks.filter((c) => c.level === "ok").length;
|
|
283
|
+
const warn = report.checks.filter((c) => c.level === "warn").length;
|
|
284
|
+
const fail = report.checks.filter((c) => c.level === "fail").length;
|
|
285
|
+
const lines = [`zero-doctor: ${report.ok ? "ok" : "falló"} · ${ok} ok · ${warn} warn · ${fail} fail`];
|
|
286
|
+
for (const c of report.checks) {
|
|
287
|
+
lines.push(`${icon(c.level)} ${c.name}: ${c.message}`);
|
|
288
|
+
if (c.hint) lines.push(` ↳ ${c.hint}`);
|
|
289
|
+
}
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
@@ -328,6 +328,85 @@ export function groupByProvider(models: readonly PiModel[]): Map<string, string[
|
|
|
328
328
|
return map;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/** Result of validating a direct `/zero-models <phase>=...` assignment
|
|
332
|
+
* against pi's model registry. */
|
|
333
|
+
export type AssignmentValidation =
|
|
334
|
+
| { ok: true; provider?: string }
|
|
335
|
+
| { ok: false; message: string };
|
|
336
|
+
|
|
337
|
+
function containsModel(list: readonly string[] | undefined, model: string): boolean {
|
|
338
|
+
return Boolean(list?.includes(model));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function modelProviders(groups: Map<string, string[]>, model: string): string[] {
|
|
342
|
+
const providers: string[] = [];
|
|
343
|
+
for (const [provider, models] of groups) if (models.includes(model)) providers.push(provider);
|
|
344
|
+
return providers.sort();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function suggestModels(groups: Map<string, string[]>, model: string): string[] {
|
|
348
|
+
const needle = model.toLowerCase();
|
|
349
|
+
const scored: string[] = [];
|
|
350
|
+
for (const [provider, models] of groups) {
|
|
351
|
+
for (const id of models) {
|
|
352
|
+
const hay = id.toLowerCase();
|
|
353
|
+
if (hay.includes(needle) || needle.includes(hay)) scored.push(`${provider}/${id}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return scored.slice(0, 5);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Validate a direct assignment against pi's model registry.
|
|
361
|
+
*
|
|
362
|
+
* Empty registry = permissive fallback (old behaviour), because tests and some
|
|
363
|
+
* headless contexts may not expose `ctx.modelRegistry`. When the registry is
|
|
364
|
+
* present, the command writes only provider/model pairs pi can actually
|
|
365
|
+
* resolve. Bare ambiguous model ids are rejected with a provider-qualified hint.
|
|
366
|
+
*/
|
|
367
|
+
export function validateAssignment(
|
|
368
|
+
assignment: Assignment,
|
|
369
|
+
groups: Map<string, string[]>,
|
|
370
|
+
): AssignmentValidation {
|
|
371
|
+
if (groups.size === 0) return { ok: true, provider: assignment.provider };
|
|
372
|
+
|
|
373
|
+
if (assignment.provider) {
|
|
374
|
+
const providerModels = groups.get(assignment.provider);
|
|
375
|
+
if (!providerModels) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
message: `provider desconocido: ${assignment.provider}. Usá uno de: ${[...groups.keys()].sort().join(", ")}`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (!containsModel(providerModels, assignment.model)) {
|
|
382
|
+
const suggestions = suggestModels(groups, assignment.model);
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
message:
|
|
386
|
+
`modelo desconocido para ${assignment.provider}: ${assignment.model}` +
|
|
387
|
+
(suggestions.length > 0 ? `. Quizás: ${suggestions.join(", ")}` : ""),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, provider: assignment.provider };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const providers = modelProviders(groups, assignment.model);
|
|
394
|
+
if (providers.length === 1) return { ok: true, provider: providers[0] };
|
|
395
|
+
if (providers.length > 1) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
message: `modelo ambiguo: ${assignment.model}. Usá provider/model: ${providers.map((p) => `${p}/${assignment.model}`).join(", ")}`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const suggestions = suggestModels(groups, assignment.model);
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
message:
|
|
405
|
+
`modelo desconocido: ${assignment.model}` +
|
|
406
|
+
(suggestions.length > 0 ? `. Quizás: ${suggestions.join(", ")}` : ""),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
331
410
|
/** The pi-TUI host handed to a `ctx.ui.custom()` factory — only the one
|
|
332
411
|
* method the picker shell uses. */
|
|
333
412
|
interface PiTui {
|
|
@@ -706,8 +785,15 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
706
785
|
);
|
|
707
786
|
return;
|
|
708
787
|
}
|
|
788
|
+
const validation = validateAssignment(assignment, groups);
|
|
789
|
+
if (!validation.ok) {
|
|
790
|
+
ctx.ui.notify(`zero-models: ${validation.message}`, "warning");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
709
794
|
models[assignment.phase] = assignment.model;
|
|
710
795
|
providers[assignment.phase] =
|
|
796
|
+
validation.provider ??
|
|
711
797
|
assignment.provider ??
|
|
712
798
|
resolveProvider(ctx.modelRegistry, assignment.model) ??
|
|
713
799
|
providers[assignment.phase];
|
|
@@ -17,6 +17,7 @@ export interface TaskRecord {
|
|
|
17
17
|
id: string;
|
|
18
18
|
title: string;
|
|
19
19
|
files: { path: string; isNew: boolean }[];
|
|
20
|
+
depends: string[] | null;
|
|
20
21
|
evidence: string | null;
|
|
21
22
|
review: number | null;
|
|
22
23
|
reviewRaw: string | null;
|
|
@@ -27,6 +28,13 @@ export interface WorkloadSection {
|
|
|
27
28
|
declaredTotal: number | null;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export function parseDepends(raw: string): string[] {
|
|
32
|
+
const value = raw.trim();
|
|
33
|
+
if (value === "" || value === "[]" || /^(none|n\/a|na)$/i.test(value)) return [];
|
|
34
|
+
const ids = [...value.matchAll(/\bT\d+\b/g)].map((m) => m[0]);
|
|
35
|
+
return [...new Set(ids)];
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
export function parseTasks(text: string): { tasks: TaskRecord[]; workload: WorkloadSection | null; defects: ValidationDefect[] } {
|
|
31
39
|
const tasks: TaskRecord[] = [];
|
|
32
40
|
const defects: ValidationDefect[] = [];
|
|
@@ -43,7 +51,7 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
43
51
|
for (const line of lines) {
|
|
44
52
|
const task = line.match(/^\s*- \[[ xX]\]\s+\*\*(T\d+)\.\s+([^*]+)\*\*/) ?? line.match(/^###\s+(T\d+)\s+[—-]\s+(.+)$/) ?? line.match(/^##\s+\[[ xX]\]\s+(T\d+)\s*(?:[—-]\s*(.+))?$/);
|
|
45
53
|
if (task) {
|
|
46
|
-
current = { id: task[1], title: (task[2] ?? "").trim(), files: [], evidence: null, review: null, reviewRaw: null };
|
|
54
|
+
current = { id: task[1], title: (task[2] ?? "").trim(), files: [], depends: null, evidence: null, review: null, reviewRaw: null };
|
|
47
55
|
tasks.push(current);
|
|
48
56
|
collectingFiles = false;
|
|
49
57
|
continue;
|
|
@@ -54,6 +62,11 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
54
62
|
pushFile(line);
|
|
55
63
|
continue;
|
|
56
64
|
}
|
|
65
|
+
if (/^\s*-\s+depends:/.test(line)) {
|
|
66
|
+
collectingFiles = false;
|
|
67
|
+
current.depends = parseDepends(line.replace(/^\s*-\s+depends:\s*/, ""));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
57
70
|
if (/^\s*-\s+evidence:/.test(line)) {
|
|
58
71
|
collectingFiles = false;
|
|
59
72
|
current.evidence = line.replace(/^\s*-\s+evidence:\s*/, "").trim();
|
|
@@ -73,8 +86,16 @@ export function parseTasks(text: string): { tasks: TaskRecord[]; workload: Workl
|
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
|
|
89
|
+
const order = new Map(tasks.map((task, index) => [task.id, index] as const));
|
|
90
|
+
for (const [index, task] of tasks.entries()) {
|
|
77
91
|
if (task.files.length === 0) defects.push({ kind: "missing-files", task: task.id, message: `${task.id} is missing files list` });
|
|
92
|
+
if (task.depends === null) defects.push({ kind: "missing-depends", task: task.id, message: `${task.id} is missing depends list` });
|
|
93
|
+
else for (const dep of task.depends) {
|
|
94
|
+
const depIndex = order.get(dep);
|
|
95
|
+
if (dep === task.id) defects.push({ kind: "self-depends", task: task.id, message: `${task.id} cannot depend on itself` });
|
|
96
|
+
else if (depIndex === undefined) defects.push({ kind: "unknown-depends", task: task.id, message: `${task.id} depends on unknown ${dep}` });
|
|
97
|
+
else if (depIndex > index) defects.push({ kind: "forward-depends", task: task.id, message: `${task.id} depends on later task ${dep}` });
|
|
98
|
+
}
|
|
78
99
|
if (task.evidence === null || task.evidence === "") defects.push({ kind: "missing-evidence", task: task.id, message: `${task.id} is missing evidence` });
|
|
79
100
|
if (task.reviewRaw === null) defects.push({ kind: "missing-review", task: task.id, message: `${task.id} is missing review estimate` });
|
|
80
101
|
else if (task.review === null) defects.push({ kind: "non-integer-review", task: task.id, message: `${task.id} review estimate is not an integer` });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (explore → plan → build → veredicto) with per-phase model autotune and token-efficient batched builds. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"./extensions/spec-merge-extension.ts",
|
|
36
36
|
"./extensions/zero-validate-extension.ts",
|
|
37
37
|
"./extensions/zero-status-extension.ts",
|
|
38
|
+
"./extensions/zero-doctor-extension.ts",
|
|
38
39
|
"./extensions/zero-cost-extension.ts",
|
|
40
|
+
"./extensions/zero-checkpoint-extension.ts",
|
|
39
41
|
"./extensions/zero-pr-extension.ts",
|
|
40
42
|
"./extensions/zero-branch-extension.ts",
|
|
41
43
|
"./extensions/zero-git-validate-extension.ts",
|
|
@@ -67,8 +69,12 @@
|
|
|
67
69
|
"extensions/zero-validate-extension.ts",
|
|
68
70
|
"extensions/zero-status.ts",
|
|
69
71
|
"extensions/zero-status-extension.ts",
|
|
72
|
+
"extensions/zero-doctor.ts",
|
|
73
|
+
"extensions/zero-doctor-extension.ts",
|
|
70
74
|
"extensions/zero-cost.ts",
|
|
71
75
|
"extensions/zero-cost-extension.ts",
|
|
76
|
+
"extensions/zero-checkpoint.ts",
|
|
77
|
+
"extensions/zero-checkpoint-extension.ts",
|
|
72
78
|
"extensions/format-tokens.ts",
|
|
73
79
|
"extensions/pr-body.ts",
|
|
74
80
|
"extensions/sdd-links.ts",
|
package/prompts/orchestrator.md
CHANGED
|
@@ -112,6 +112,14 @@ findings, file dumps — into a brief; reference them by path. Re-passing contex
|
|
|
112
112
|
the sub-agent can read for itself is wasted tokens on every invocation, and a
|
|
113
113
|
batched build issues many briefs.
|
|
114
114
|
|
|
115
|
+
## Plan quality gate
|
|
116
|
+
|
|
117
|
+
After **plan** returns, run `/zero-validate <slug>` when the command is available. Treat structural validation errors as a plan failure: summarize the defects, re-run **plan** with those exact defects once, and validate again before entering build. Warnings (for example an intentionally-missing optional proposal) can proceed only when you name the warning in the phase summary. The gate specifically protects the dependency graph: every task must carry `files`, `depends`, `evidence`, and `review`; dependencies must point backward to known task ids; and the review workload total must match.
|
|
118
|
+
|
|
119
|
+
## Pre-build checkpoint
|
|
120
|
+
|
|
121
|
+
Before the first **build** batch of every round, run `/zero-checkpoint <slug>` when available. It writes a patch-based checkpoint under `.sdd/<slug>/checkpoints/<id>/` so a risky build has a concrete restore trail. If the command is missing or reports untracked files that were not captured, continue only after surfacing that risk in the build phase-start summary. Do not run destructive restore commands automatically; the checkpoint only records evidence and a reviewed `restore.sh`.
|
|
122
|
+
|
|
115
123
|
## Build batching
|
|
116
124
|
|
|
117
125
|
The **build** phase is not one monolithic sub-agent that implements every
|
|
@@ -122,23 +130,30 @@ bounded batches, each a fresh `zero-build` sub-agent.
|
|
|
122
130
|
Before delegating build — a fresh build phase, or a `corregir`/`replantear`
|
|
123
131
|
re-run — drive this loop:
|
|
124
132
|
|
|
125
|
-
1. Read the unchecked (`[ ]`) tasks from `tasks.md` in listed order
|
|
126
|
-
`review: ~N changed lines` estimates from the
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
1. Read the unchecked (`[ ]`) tasks from `tasks.md` in listed order, their
|
|
134
|
+
`depends:` edges, and their `review: ~N changed lines` estimates from the
|
|
135
|
+
`## Review Workload` section.
|
|
136
|
+
2. Treat the task order as a validated topological order. A task is eligible for
|
|
137
|
+
the next batch only when every dependency in its `depends:` list is already
|
|
138
|
+
checked `[x]` or is also included earlier in the same contiguous batch. If an
|
|
139
|
+
unchecked task depends on a later/unknown/unchecked-outside-batch task, stop
|
|
140
|
+
and re-run **plan** — do not improvise a new order in build.
|
|
141
|
+
3. Group the eligible unchecked tasks into ordered batches with this exact,
|
|
142
|
+
deterministic rule:
|
|
129
143
|
- Walk the unchecked tasks in order, accumulating into the current batch.
|
|
130
144
|
- Start a new batch when adding the next task would push the batch's summed
|
|
131
145
|
estimate over **800 changed lines**, or when the current batch already
|
|
132
146
|
holds **4 tasks** — whichever comes first.
|
|
133
147
|
- A single task whose own estimate exceeds 800 is its own batch.
|
|
134
148
|
- If estimates are missing or unparseable, group by the 4-task cap alone.
|
|
135
|
-
|
|
149
|
+
4. Invoke `zero-build` once per batch, in listed order. Each brief is a fresh
|
|
136
150
|
sub-agent (no carried conversation) and names the batch's task numbers
|
|
137
|
-
explicitly (for example, "implement tasks 4–6 only, then return").
|
|
138
|
-
|
|
139
|
-
the
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
explicitly (for example, "implement tasks 4–6 only, then return"). Include the
|
|
152
|
+
dependency constraint in the brief: "do not start a task until its `depends:`
|
|
153
|
+
entries are `[x]`." Emit the build phase-start line for each batch, noting
|
|
154
|
+
the batch as `lote <i>/<n>`, so the loop stays visible. Wait for each batch to
|
|
155
|
+
return before starting the next.
|
|
156
|
+
5. Repeat until `tasks.md` has no `[ ]` task left, then run **veredicto** once.
|
|
142
157
|
Never run veredicto between batches.
|
|
143
158
|
|
|
144
159
|
**Single-batch features behave exactly like before:** when every unchecked task
|
package/prompts/phases/build.md
CHANGED
|
@@ -23,8 +23,7 @@ re-reading the same large file repeatedly is the main avoidable token cost. If
|
|
|
23
23
|
the code roots are missing or wrong, run a single targeted search to fix them,
|
|
24
24
|
then proceed — never fall back to scanning the whole tree.
|
|
25
25
|
|
|
26
|
-
Implement the planned tasks in order, test-first where practical. Keep every
|
|
27
|
-
change within the plan's scope — do not expand it on your own initiative.
|
|
26
|
+
Implement the planned tasks in dependency order, test-first where practical. `tasks.md` is a dependency-aware graph: every task has a `depends:` line. Before starting a task, verify each listed dependency is already `[x]`; if a dependency is unchecked, complete that dependency first (when it is in your assigned batch) or stop and report the blocked task (when it is outside the assigned batch). Never skip ahead just because a later task looks easier. Keep every change within the plan's scope — do not expand it on your own initiative.
|
|
28
27
|
|
|
29
28
|
## Strict TDD gate
|
|
30
29
|
|
package/prompts/phases/plan.md
CHANGED
|
@@ -73,6 +73,7 @@ Use this exact checklist shape so build/validate/review can parse it without red
|
|
|
73
73
|
- `<root>/src/example.ts`
|
|
74
74
|
- `<root>/src/example.test.ts` (new)
|
|
75
75
|
- detail: concrete implementation notes and boundaries.
|
|
76
|
+
- depends: []
|
|
76
77
|
- evidence: `npm test -- example` passes, or the exact manual check expected.
|
|
77
78
|
- review: ~120 changed lines
|
|
78
79
|
```
|
|
@@ -81,9 +82,14 @@ Rules:
|
|
|
81
82
|
- Task ids are monotonic `T###`; keep existing completed ids stable on resume.
|
|
82
83
|
- Add `[P]` only for tasks that are truly parallel-safe, and `[Story:S1]`/`[Story:S2]` when the plan has independently testable stories.
|
|
83
84
|
- `files:` is mandatory and uses exact paths; append `(new)` for created files.
|
|
85
|
+
- `depends:` is mandatory. Use `depends: []` for root tasks, or a comma-separated list of earlier task ids (`depends: T001, T002`) for prerequisites. Dependencies must point only backward so the written task order is already a valid topological order.
|
|
84
86
|
- `evidence:` is mandatory and names a concrete verification command or artifact.
|
|
85
87
|
- Keep `## Review Workload` present and synchronized with the task list.
|
|
86
88
|
|
|
89
|
+
## Dependency graph discipline
|
|
90
|
+
|
|
91
|
+
Treat `tasks.md` as a dependency-aware task graph, not just a checklist. Split work so every task has explicit prerequisites, no self-dependencies, no unknown dependencies, and no dependency on a later task. Use the graph to mark real parallelism: a task can get `[P]` only when all of its dependencies are complete and it does not touch the same files as another candidate parallel task.
|
|
92
|
+
|
|
87
93
|
## TDD-shaped tasks
|
|
88
94
|
|
|
89
95
|
Unless the project sets `tdd.mode: "off"` in `.sdd/config.json`, the build phase
|
|
@@ -109,6 +115,8 @@ GREEN cycle and the TDD Cycle Evidence table.
|
|
|
109
115
|
|
|
110
116
|
## Constitution / Steering check
|
|
111
117
|
|
|
118
|
+
Before finalizing tasks, run `/zero-validate <slug>` when available, or manually apply the same checks: all four artifacts present, every task has `files`, `depends`, `evidence`, and `review`, dependency edges point backward to known task ids, review workload totals match, and the spec delta passes guardrails. Fix the artifacts before returning if the validation fails.
|
|
119
|
+
|
|
112
120
|
Before finalizing tasks, check project steering/constitution files when present (`.sdd/constitution.md`, `.sdd/steering.md`, `.kiro/steering/*`, or project equivalents). If absent, mark `n/a`; absence is not a blocker. Include this table in `design.md` or `tasks.md`:
|
|
113
121
|
|
|
114
122
|
| rule | status | waiver |
|