pi-super-dev 0.1.0
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/agents/adversarial-reviewer.md +64 -0
- package/agents/architecture-designer.md +43 -0
- package/agents/architecture-improver.md +46 -0
- package/agents/bdd-scenario-writer.md +37 -0
- package/agents/build-cleaner.md +44 -0
- package/agents/code-assessor.md +24 -0
- package/agents/code-reviewer.md +59 -0
- package/agents/debug-analyzer.md +54 -0
- package/agents/docs-executor.md +49 -0
- package/agents/handoff-writer.md +62 -0
- package/agents/implementer.md +47 -0
- package/agents/orchestrator.md +42 -0
- package/agents/product-designer.md +42 -0
- package/agents/prototype-runner.md +36 -0
- package/agents/qa-agent.md +76 -0
- package/agents/requirements-clarifier.md +58 -0
- package/agents/research-agent.md +33 -0
- package/agents/spec-reviewer.md +46 -0
- package/agents/spec-writer.md +32 -0
- package/agents/tdd-guide.md +51 -0
- package/agents/ui-ux-designer.md +50 -0
- package/package.json +40 -0
- package/skills/super-dev/SKILL.md +35 -0
- package/src/agents.ts +38 -0
- package/src/control.ts +85 -0
- package/src/doc-validators.ts +164 -0
- package/src/extension.ts +164 -0
- package/src/helpers.ts +263 -0
- package/src/nodes.ts +550 -0
- package/src/pi-spawn.ts +296 -0
- package/src/pipeline.ts +15 -0
- package/src/prompts.ts +120 -0
- package/src/session-agent.ts +305 -0
- package/src/setup.ts +141 -0
- package/src/stages/design.ts +33 -0
- package/src/stages/implementation.ts +80 -0
- package/src/stages/index.ts +172 -0
- package/src/stages/prototype.ts +43 -0
- package/src/stages/setup.ts +32 -0
- package/src/stages/writers.ts +105 -0
- package/src/types.ts +235 -0
- package/src/workflow.ts +181 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process specialist execution via the pi SDK (`createAgentSession`).
|
|
3
|
+
*
|
|
4
|
+
* This is the alternative to {@link spawnAgent} (raw `pi` subprocess). It runs a
|
|
5
|
+
* specialist in-process, in-memory, and captures its result via a
|
|
6
|
+
* `structured_output` tool (schema-validated) instead of parsing `<control>`
|
|
7
|
+
* text from subprocess stdout. Same return contract as spawnAgent
|
|
8
|
+
* ({@link SpawnResult}) so the workflow engine is unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Why: the subprocess path carried a whole class of bugs (spawn ENOENT,
|
|
11
|
+
* RangeError on stdout buffering, <control> parse fragility, process timeouts).
|
|
12
|
+
* The session path uses the same `@earendil-works/pi-coding-agent` SDK we
|
|
13
|
+
* already peer-depend on — no new dependency — and gets structured output,
|
|
14
|
+
* abort, and host config reuse (auth/model) for free.
|
|
15
|
+
*
|
|
16
|
+
* Select at runtime via `ctx.agent` (see workflow.ts): backend "session" uses
|
|
17
|
+
* this; "subprocess" uses spawnAgent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createAgentSession,
|
|
22
|
+
createCodingTools,
|
|
23
|
+
defineTool,
|
|
24
|
+
getAgentDir,
|
|
25
|
+
type ToolDefinition,
|
|
26
|
+
SessionManager,
|
|
27
|
+
SettingsManager,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Type } from "typebox";
|
|
30
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { loadAgentPrompt } from "./agents.ts";
|
|
34
|
+
import { extractControl } from "./control.ts";
|
|
35
|
+
import { sanitizeSlug } from "./setup.ts";
|
|
36
|
+
import type { AgentProgress, SpawnResult } from "./types.ts";
|
|
37
|
+
|
|
38
|
+
export interface SessionAgentOptions {
|
|
39
|
+
agent: string;
|
|
40
|
+
prompt: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
id?: string;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
/** Control keys the caller expects in structured_output (declares them in the
|
|
47
|
+
* tool schema so the model fills them). When omitted, a fully permissive
|
|
48
|
+
* schema is used. Derived from the prompt by workflow.ts. */
|
|
49
|
+
controlKeys?: string[];
|
|
50
|
+
onProgress?: AgentProgress;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build the structured_output schema. When `keys` is non-empty, each key is
|
|
54
|
+
* DECLARED (Optional, Any) so the model treats it as part of the contract and
|
|
55
|
+
* fills it — this is the fix for the requirements-gate failure, where a
|
|
56
|
+
* schema that declared only `summary` made GLM return only `summary`. Keys
|
|
57
|
+
* stay Optional so tool validation never rejects a partially-filled object;
|
|
58
|
+
* completeness is enforced by the corrective re-prompt below. */
|
|
59
|
+
function controlSchema(keys: string[]) {
|
|
60
|
+
const props: Record<string, ReturnType<typeof Type.Any>> = {};
|
|
61
|
+
for (const k of keys) props[k] = Type.Optional(Type.Any());
|
|
62
|
+
return Type.Object(props, { additionalProperties: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Which declared keys are missing/blank in the captured control object. */
|
|
66
|
+
export function missingKeys(captured: Record<string, unknown> | null | undefined, keys: string[]): string[] {
|
|
67
|
+
if (!captured) return keys;
|
|
68
|
+
return keys.filter((k) => {
|
|
69
|
+
const v = captured[k];
|
|
70
|
+
return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Capture {
|
|
75
|
+
called: boolean;
|
|
76
|
+
value: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build the terminating structured_output tool that captures the result.
|
|
80
|
+
* The schema DECLARES the expected keys (see controlSchema) so the model
|
|
81
|
+
* fills them instead of dumping everything into one field. */
|
|
82
|
+
function structuredOutputTool(capture: Capture, keys: string[]): ToolDefinition {
|
|
83
|
+
const fieldList = keys.length ? keys.join(", ") : "the fields the task requested";
|
|
84
|
+
return defineTool({
|
|
85
|
+
name: "structured_output",
|
|
86
|
+
label: "Structured Output",
|
|
87
|
+
description: `Return the final result object. It MUST include every one of these keys: ${fieldList}.`,
|
|
88
|
+
promptSnippet: "Return final machine-readable result",
|
|
89
|
+
promptGuidelines: [
|
|
90
|
+
`structured_output is the final answer channel; call it exactly once when the task is complete. Your object MUST contain ALL of: ${fieldList}.`,
|
|
91
|
+
"Do not write a prose final answer after calling structured_output.",
|
|
92
|
+
],
|
|
93
|
+
parameters: controlSchema(keys),
|
|
94
|
+
async execute(_toolCallId, params) {
|
|
95
|
+
capture.value = { ...(capture.value as Record<string, unknown> | undefined), ...params };
|
|
96
|
+
capture.called = true;
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: "Structured output received." }],
|
|
99
|
+
details: params,
|
|
100
|
+
terminate: true,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Live progress forwarding from session events → the sink. Session events
|
|
107
|
+
* nest streaming under `message_update.assistantMessageEvent` (text_delta /
|
|
108
|
+
* text_end carry `partial.content` with the accumulated block text); tool calls
|
|
109
|
+
* arrive as top-level `tool_execution_start`. Text partials reset per message
|
|
110
|
+
* block, so finalizing at each tool call doesn't duplicate prefixes. */
|
|
111
|
+
function forwardProgress(session: { subscribe(listener: (e: unknown) => void): () => void }, onProgress: AgentProgress): () => void {
|
|
112
|
+
let turns = 0;
|
|
113
|
+
let lastText = ""; // dedup: only forward text when it changes; reset per tool block
|
|
114
|
+
return session.subscribe((event: unknown) => {
|
|
115
|
+
const e = event as { type?: string; toolName?: string; args?: Record<string, unknown>; assistantMessageEvent?: { type?: string; partial?: { content?: Array<{ type: string; text?: string }> } } };
|
|
116
|
+
if (!e?.type) return;
|
|
117
|
+
if (e.type === "tool_execution_start" && e.toolName) {
|
|
118
|
+
lastText = "";
|
|
119
|
+
onProgress.event(`→ ${summarize(e.toolName, e.args)}`);
|
|
120
|
+
} else if (e.type === "turn_start") {
|
|
121
|
+
if (++turns > 1) onProgress.event(`turn ${turns}`);
|
|
122
|
+
} else if (e.type === "message_update") {
|
|
123
|
+
const a = e.assistantMessageEvent;
|
|
124
|
+
if (a?.type === "text_delta" || a?.type === "text_end") {
|
|
125
|
+
const text = (a.partial?.content ?? []).filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
126
|
+
const clean = text.replace(/<control>[\s\S]*?<\/control>/gi, "").trim();
|
|
127
|
+
if (clean && clean !== lastText) {
|
|
128
|
+
lastText = clean;
|
|
129
|
+
onProgress.text(clean);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function summarize(name: string, args: Record<string, unknown> | undefined): string {
|
|
137
|
+
const a = args ?? {};
|
|
138
|
+
switch (name) {
|
|
139
|
+
case "write": case "edit": case "read": return `${name} ${a.path ?? a.file_path ?? ""}`;
|
|
140
|
+
case "bash": return `$ ${String(a.command ?? "").split("\n")[0]}`;
|
|
141
|
+
case "ffgrep": case "fffind": return `${name} "${a.pattern ?? ""}"`;
|
|
142
|
+
default: return name === "structured_output" ? "structured_output ✓" : name;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lastAssistantText(messages: Array<{ role?: string; content?: Array<{ type: string; text?: string }> }>): string {
|
|
147
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
148
|
+
const m = messages[i];
|
|
149
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
150
|
+
const t = m.content.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text as string).join("");
|
|
151
|
+
if (t.trim()) return t;
|
|
152
|
+
}
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Ask the model for a concise 2-5 word kebab-case slug summarizing the task.
|
|
157
|
+
* Minimal session: no coding tools, only a structured_output tool — fast and
|
|
158
|
+
* cheap. Returns "" on any failure/timeout so the caller can fall back to the
|
|
159
|
+
* deterministic slugifyTask. */
|
|
160
|
+
export async function summarizeSlug(task: string, cwd: string, opts: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<string> {
|
|
161
|
+
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
162
|
+
const capture: Capture = { called: false, value: undefined };
|
|
163
|
+
const agentDir = getAgentDir();
|
|
164
|
+
let session;
|
|
165
|
+
try {
|
|
166
|
+
({ session } = await createAgentSession({
|
|
167
|
+
cwd,
|
|
168
|
+
agentDir,
|
|
169
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
170
|
+
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
171
|
+
customTools: [defineTool({
|
|
172
|
+
name: "structured_output",
|
|
173
|
+
label: "Slug",
|
|
174
|
+
description: "Return the summary slug.",
|
|
175
|
+
promptSnippet: "Return the slug",
|
|
176
|
+
promptGuidelines: ["Call structured_output once with the slug."],
|
|
177
|
+
parameters: Type.Object({ slug: Type.String() }),
|
|
178
|
+
async execute(_id, params) { capture.value = params; capture.called = true; return { content: [{ type: "text", text: "ok" }], details: params, terminate: true }; },
|
|
179
|
+
})],
|
|
180
|
+
}));
|
|
181
|
+
} catch {
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
const timer = setTimeout(() => { try { void session.abort(); } catch { /* ignore */ } }, timeoutMs);
|
|
185
|
+
const onAbort = () => void session.abort();
|
|
186
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
187
|
+
try {
|
|
188
|
+
await session.prompt(`Summarize this software task into a concise 2-5 word kebab-case slug (lowercase, words joined by single hyphens, no articles or filler words like "implement/add/feature"). Task:\n"""${task}"""\nCall structured_output with {slug}.`);
|
|
189
|
+
} catch { /* timeout/abort → fallback */ }
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
session.dispose();
|
|
193
|
+
const raw = capture.called ? String((capture.value as { slug?: unknown })?.slug ?? "") : "";
|
|
194
|
+
return sanitizeSlug(raw);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Run a specialist in-process and return its result (SpawnResult contract).
|
|
198
|
+
* Per-stage `controlKeys` are declared in the structured_output schema so the
|
|
199
|
+
* model fills them. If the first turn omits any, a single corrective re-prompt
|
|
200
|
+
* is sent IN THE SAME SESSION (context preserved) before giving up — this is
|
|
201
|
+
* what turns the old "gate failed after 3 attempts" into a self-healing step.
|
|
202
|
+
* Set SUPER_DEV_DEBUG=1 to dump the full per-agent message trace to a temp
|
|
203
|
+
* file (sessions are otherwise in-memory and unobservable). */
|
|
204
|
+
export async function runAgentViaSession(opts: SessionAgentOptions): Promise<SpawnResult> {
|
|
205
|
+
const systemPrompt = loadAgentPrompt(opts.agent);
|
|
206
|
+
const keys = opts.controlKeys ?? [];
|
|
207
|
+
const capture: Capture = { called: false, value: undefined };
|
|
208
|
+
const timeoutMs = opts.timeoutMs ?? 480_000;
|
|
209
|
+
|
|
210
|
+
const agentDir = getAgentDir();
|
|
211
|
+
const { session } = await createAgentSession({
|
|
212
|
+
cwd: opts.cwd,
|
|
213
|
+
agentDir,
|
|
214
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
215
|
+
settingsManager: SettingsManager.create(opts.cwd, agentDir),
|
|
216
|
+
customTools: [...createCodingTools(opts.cwd), structuredOutputTool(capture, keys)],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const unsub = opts.onProgress ? forwardProgress(session, opts.onProgress) : undefined;
|
|
220
|
+
let timedOut = false;
|
|
221
|
+
const onAbort = () => void session.abort();
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
timedOut = true;
|
|
224
|
+
try { void session.abort(); } catch { /* ignore */ }
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
227
|
+
|
|
228
|
+
const finalOutputLine = keys.length
|
|
229
|
+
? `When the task is complete, call the \`structured_output\` tool exactly once with an object containing ALL of these keys: ${keys.join(", ")}. Do not omit any. Do not emit a prose final answer after that.`
|
|
230
|
+
: "When the task is complete, call the `structured_output` tool exactly once with an object containing the fields requested above. Do not emit a prose final answer after that.";
|
|
231
|
+
// Delivery discipline — the systemic fix for the recurring "agent explores for
|
|
232
|
+
// 10-27 tool calls then times out before writing" pattern. The ported agent
|
|
233
|
+
// prompts demand Claude-grade exhaustive verification; glm is slower and runs
|
|
234
|
+
// out of time. This preamble overrides that: bound exploration, write early.
|
|
235
|
+
const deliveryDiscipline = [
|
|
236
|
+
"## Delivery discipline (OVERRIDES any contrary instruction above)",
|
|
237
|
+
"You have a LIMITED time budget. The ONLY deliverable that matters is the written document + your structured_output call.",
|
|
238
|
+
"- Explore with AT MOST ~6 tool calls total (read/bash/grep/web). You do NOT need to read every file, run the full test suite, or verify every claim independently.",
|
|
239
|
+
"- Never re-read a file you already read. Never loop on self-auditing, self-scoring, or revision.",
|
|
240
|
+
"- START WRITING the document once you have the gist — well before you feel 'done' exploring. Written-but-imperfect beats thorough-but-unfinished (a timeout produces NOTHING).",
|
|
241
|
+
"- After writing, immediately call structured_output and STOP.",
|
|
242
|
+
].join("\n");
|
|
243
|
+
const task = [systemPrompt, "", "## Task", opts.prompt, "", deliveryDiscipline, "", "## Final output", finalOutputLine].join("\n");
|
|
244
|
+
|
|
245
|
+
let correctiveNote = "";
|
|
246
|
+
try {
|
|
247
|
+
try {
|
|
248
|
+
await session.prompt(task);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (!timedOut && !opts.signal?.aborted) throw err;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Self-heal: ONLY when the model actually called structured_output but
|
|
254
|
+
// omitted declared keys, send ONE corrective turn in the same session
|
|
255
|
+
// (same context, same files written) naming exactly what's missing. If it
|
|
256
|
+
// never called the tool, a "you omitted keys" message would be a false
|
|
257
|
+
// premise — leave that to the gate's cold retry instead.
|
|
258
|
+
const afterFirst = capture.called ? (capture.value as Record<string, unknown> | undefined) : undefined;
|
|
259
|
+
const missing = missingKeys(afterFirst, keys);
|
|
260
|
+
if (capture.called && missing.length > 0 && !timedOut && !opts.signal?.aborted) {
|
|
261
|
+
correctiveNote = `corrective re-prompt (missing: ${missing.join(", ")})`;
|
|
262
|
+
opts.onProgress?.event(`↻ ${opts.id ?? opts.agent}: ${correctiveNote}`);
|
|
263
|
+
const fix = `Your previous structured_output was missing required keys: ${missing.join(", ")}. Call structured_output AGAIN, this time with ALL of these keys filled from the work you already did: ${keys.join(", ")}. Do not redo the work — just return the complete object.`;
|
|
264
|
+
try {
|
|
265
|
+
await session.prompt(fix);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (!timedOut && !opts.signal?.aborted) throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const text = lastAssistantText(session.messages as Parameters<typeof lastAssistantText>[0]);
|
|
272
|
+
const control = capture.called ? (capture.value as Record<string, unknown>) : extractControl(text);
|
|
273
|
+
return { text, control: control ?? null, error: timedOut ? `timed out after ${Math.round(timeoutMs / 1000)}s${capture.called ? " (structured_output captured before abort)" : ""}` : undefined };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return { text: "", control: null, error: err instanceof Error ? err.message : String(err) };
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
279
|
+
unsub?.();
|
|
280
|
+
if (process.env.SUPER_DEV_DEBUG) dumpTrace(opts, keys, capture, correctiveNote, session.messages);
|
|
281
|
+
session.dispose();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Write the full in-memory message trace to a temp file. The session backend
|
|
286
|
+
* keeps everything in memory (SessionManager.inMemory), so without this there
|
|
287
|
+
* are zero logs to debug a failed/garbled agent run. */
|
|
288
|
+
function dumpTrace(opts: SessionAgentOptions, keys: string[], capture: Capture, correctiveNote: string, messages: unknown): void {
|
|
289
|
+
try {
|
|
290
|
+
const dir = join(tmpdir(), "super-dev-debug");
|
|
291
|
+
mkdirSync(dir, { recursive: true });
|
|
292
|
+
const safe = (opts.id ?? opts.agent).replace(/[^A-Za-z0-9_.-]+/g, "_");
|
|
293
|
+
const file = join(dir, `${Date.now()}-${safe}.json`);
|
|
294
|
+
writeFileSync(file, JSON.stringify({
|
|
295
|
+
agent: opts.agent,
|
|
296
|
+
id: opts.id,
|
|
297
|
+
cwd: opts.cwd,
|
|
298
|
+
controlKeys: keys,
|
|
299
|
+
structuredOutputCalled: capture.called,
|
|
300
|
+
structuredOutputValue: capture.value,
|
|
301
|
+
correctiveNote,
|
|
302
|
+
messages,
|
|
303
|
+
}, null, 2));
|
|
304
|
+
} catch { /* best-effort */ }
|
|
305
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic setup stage — detects language/framework, derives a spec id,
|
|
3
|
+
* creates a git worktree (unless skipped), and creates the spec directory.
|
|
4
|
+
* Replaces the original LLM-driven setup agent; no model round-trip.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import type { SetupControl } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
function git(args: string[], cwd: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectLanguage(cwd: string, task = ""): { language: string; isWebUi: boolean } {
|
|
21
|
+
const has = (f: string) => existsSync(join(cwd, f));
|
|
22
|
+
if (has("Cargo.toml")) return { language: "rust", isWebUi: false };
|
|
23
|
+
if (has("go.mod")) return { language: "go", isWebUi: false };
|
|
24
|
+
if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) return { language: "python", isWebUi: false };
|
|
25
|
+
if (has("package.json")) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
28
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
29
|
+
const isWebUi = Boolean(deps["react"] || deps["next"] || deps["vue"] || deps["svelte"] || deps["@sveltejs/kit"]);
|
|
30
|
+
if (deps["express"] || deps["fastify"] || deps["@hono/node-server"]) return { language: "backend", isWebUi };
|
|
31
|
+
return { language: "frontend", isWebUi };
|
|
32
|
+
} catch {
|
|
33
|
+
return { language: "frontend", isWebUi: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Greenfield (no manifest): infer the target stack from the task text so
|
|
37
|
+
// downstream prompts and the implementation know what to build.
|
|
38
|
+
const t = task.toLowerCase();
|
|
39
|
+
const mentions = (...kw: string[]) => kw.some((k) => t.includes(k));
|
|
40
|
+
if (mentions("node", "nodejs", "node.js", "express", "fastify", "npm", "deno", "bun")) return { language: "backend", isWebUi: false };
|
|
41
|
+
if (mentions("python", "django", "flask", "fastapi", "pip")) return { language: "python", isWebUi: false };
|
|
42
|
+
if (mentions("golang") || /\bgo\b/.test(t)) return { language: "go", isWebUi: false };
|
|
43
|
+
if (mentions("rust", "cargo")) return { language: "rust", isWebUi: false };
|
|
44
|
+
return { language: "mixed", isWebUi: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sanitize any string (LLM output or raw) into a kebab-case slug, truncated at
|
|
48
|
+
* a word boundary so it never cuts mid-word. */
|
|
49
|
+
export function sanitizeSlug(raw: string): string {
|
|
50
|
+
let s = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
51
|
+
if (s.length > 40) { s = s.slice(0, 40); const c = s.lastIndexOf("-"); if (c > 8) s = s.slice(0, c); }
|
|
52
|
+
return s.replace(/-+$/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Deterministic fallback slug: drop filler words, keep up to ~5 content words. */
|
|
56
|
+
const STOPWORDS = new Set("a an the to of for and or nor but in on at by with from into is are be as that this it its our your their we you they please need want implement add build create make new feature features simple app application page use using used based get one two three next".split(" "));
|
|
57
|
+
export function slugifyTask(task: string): string {
|
|
58
|
+
const words = task.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w && !STOPWORDS.has(w));
|
|
59
|
+
return sanitizeSlug(words.slice(0, 5).join("-")) || "task";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nextSpecNumber(cwd: string): number {
|
|
63
|
+
const specsDir = join(cwd, "docs", "specifications");
|
|
64
|
+
let max = 0;
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of readdirSync(specsDir)) {
|
|
67
|
+
const m = entry.match(/^(\d+)-/);
|
|
68
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
69
|
+
}
|
|
70
|
+
} catch { /* no specs dir yet */ }
|
|
71
|
+
return max + 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function detectDefaultBranch(cwd: string): string {
|
|
75
|
+
const fromOrigin = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
|
|
76
|
+
if (fromOrigin && fromOrigin.startsWith("origin/")) return fromOrigin.slice("origin/".length);
|
|
77
|
+
const current = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
78
|
+
if (current && current !== "HEAD") return current;
|
|
79
|
+
return "main";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isGitRepo(cwd: string): boolean {
|
|
83
|
+
return git(["rev-parse", "--is-inside-work-tree"], cwd) !== null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function headExists(cwd: string): boolean {
|
|
87
|
+
return git(["rev-parse", "--verify", "HEAD"], cwd) !== null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureGitIdentity(cwd: string): void {
|
|
91
|
+
if (!git(["config", "user.email"], cwd)) git(["config", "user.email", "pi-super-dev@local"], cwd);
|
|
92
|
+
if (!git(["config", "user.name"], cwd)) git(["config", "user.name", "pi-super-dev"], cwd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SetupOptions {
|
|
96
|
+
cwd?: string;
|
|
97
|
+
skipWorktree?: boolean;
|
|
98
|
+
/** Descriptive slug for the spec id (e.g. LLM-summarized). Falls back to
|
|
99
|
+
* slugifyTask(task) when empty/invalid. */
|
|
100
|
+
slug?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runSetup(task: string, options: SetupOptions = {}): SetupControl {
|
|
104
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
105
|
+
|
|
106
|
+
// Ensure cwd is a git repo (worktree + later commits/merge require it).
|
|
107
|
+
let initializedRepo = false;
|
|
108
|
+
if (!isGitRepo(cwd)) {
|
|
109
|
+
git(["init"], cwd);
|
|
110
|
+
initializedRepo = true;
|
|
111
|
+
}
|
|
112
|
+
// A worktree (and later commits/merge) needs at least one commit on the
|
|
113
|
+
// base branch. Empty repos with an unborn HEAD break `git worktree add`
|
|
114
|
+
// ("fatal: invalid reference: main"), causing setup to silently fall back
|
|
115
|
+
// to operating in the cwd with no isolation.
|
|
116
|
+
if (!headExists(cwd)) {
|
|
117
|
+
ensureGitIdentity(cwd);
|
|
118
|
+
git(["commit", "--allow-empty", "-m", "chore: initial commit (pi-super-dev)"], cwd);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { language, isWebUi } = detectLanguage(cwd, task);
|
|
122
|
+
const defaultBranch = detectDefaultBranch(cwd);
|
|
123
|
+
const slug = sanitizeSlug(options.slug ?? "") || slugifyTask(task);
|
|
124
|
+
const specIdentifier = `${String(nextSpecNumber(cwd)).padStart(2, "0")}-${slug}`;
|
|
125
|
+
|
|
126
|
+
let worktreePath = cwd;
|
|
127
|
+
let worktreeCreated = false;
|
|
128
|
+
if (!options.skipWorktree) {
|
|
129
|
+
const wtPath = join(cwd, ".worktree", specIdentifier);
|
|
130
|
+
const created = git(["worktree", "add", "-b", specIdentifier, wtPath, defaultBranch], cwd);
|
|
131
|
+
if (created !== null || existsSync(wtPath)) {
|
|
132
|
+
worktreePath = wtPath;
|
|
133
|
+
worktreeCreated = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const specDirectory = join(worktreePath, "docs", "specifications", specIdentifier) + "/";
|
|
138
|
+
mkdirSync(specDirectory, { recursive: true });
|
|
139
|
+
|
|
140
|
+
return { worktreePath, specDirectory, defaultBranch, language, isWebUi, specIdentifier, worktreeCreated, initializedRepo };
|
|
141
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 6 — Design (routed).
|
|
3
|
+
* Self-contained task: route-designer helper picks the specialist designer
|
|
4
|
+
* (or skips for bug fixes), then spawns it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Stage } from "../types.ts";
|
|
8
|
+
import { buildDesignPrompt } from "../prompts.ts";
|
|
9
|
+
|
|
10
|
+
export const designStage: Stage = {
|
|
11
|
+
id: "design",
|
|
12
|
+
label: "Stage 6 — Design",
|
|
13
|
+
async run(state, ctx) {
|
|
14
|
+
const routing = await ctx.helper({ name: "route-designer", sources: { "classify-task": state.classify } });
|
|
15
|
+
const designerAgent = (routing.value.designerAgent as string) ?? null;
|
|
16
|
+
if (!designerAgent) {
|
|
17
|
+
ctx.log(`Design skipped: ${routing.value.reason as string}`);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!ctx.budget.check()) {
|
|
21
|
+
ctx.log("Design: budget exhausted");
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const setup = state.setup!;
|
|
25
|
+
const result = await ctx.agent({
|
|
26
|
+
id: "pipeline.design",
|
|
27
|
+
agent: designerAgent,
|
|
28
|
+
prompt: buildDesignPrompt(setup, state.classify ?? null, ctx.task, state.requirements ?? null, state.research ?? null, state.assessment ?? null, designerAgent),
|
|
29
|
+
});
|
|
30
|
+
ctx.log(`Design complete (agent: ${designerAgent})`);
|
|
31
|
+
return result.control ?? null;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 9 — Implementation (per-phase TDD).
|
|
3
|
+
* Self-contained task: iterates the spec's phased task list. For each phase,
|
|
4
|
+
* up to 3 attempts of TDD-write → implement → QA → build-gate; commits on green.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ControlObj, Stage } from "../types.ts";
|
|
8
|
+
import { buildTddPrompt, buildImplementPrompt, buildQaPrompt, buildCommitPrompt, buildImplementationSummaryPrompt } from "../prompts.ts";
|
|
9
|
+
import { normalizePhases } from "../doc-validators.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_ATTEMPTS = 3;
|
|
12
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
13
|
+
|
|
14
|
+
export const implementationStage: Stage = {
|
|
15
|
+
id: "implementation",
|
|
16
|
+
label: "Stage 9 — Implementation",
|
|
17
|
+
async run(state, ctx) {
|
|
18
|
+
// Defensively normalize: agents sometimes return `phases` as a string or
|
|
19
|
+
// object instead of an array, which crashed `phases.entries()` (Stage 9:
|
|
20
|
+
// "phases.entries is not a function"). Never trust the control shape.
|
|
21
|
+
const phases = normalizePhases(state.spec?.phases);
|
|
22
|
+
if (!Array.isArray(state.spec?.phases) && state.spec?.phases != null) {
|
|
23
|
+
ctx.log(`Implementation: spec.phases was ${typeof state.spec.phases}, expected an array — normalized to ${phases.length} phase(s)`);
|
|
24
|
+
}
|
|
25
|
+
if (phases.length === 0) {
|
|
26
|
+
ctx.log("Implementation: no phases defined in spec — skipping");
|
|
27
|
+
return { phasesCompleted: 0, totalPhases: 0, allGreen: false };
|
|
28
|
+
}
|
|
29
|
+
const setup = state.setup!;
|
|
30
|
+
let phasesCompleted = 0;
|
|
31
|
+
let allGreen = true;
|
|
32
|
+
const filesModified: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (const [idx, phase] of phases.entries()) {
|
|
35
|
+
const phaseId = `phase-${pad(idx + 1)}`;
|
|
36
|
+
let green = false;
|
|
37
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
38
|
+
if (!ctx.budget.check()) {
|
|
39
|
+
allGreen = false;
|
|
40
|
+
return { phasesCompleted, totalPhases: phases.length, allGreen, filesModified, summary: "Budget exhausted" };
|
|
41
|
+
}
|
|
42
|
+
await ctx.agent({ id: `pipeline.implementation.${phaseId}.tdd.a${attempt}`, agent: "tdd-guide", prompt: buildTddPrompt(setup, state.classify ?? null, phase, state.spec ?? null) });
|
|
43
|
+
const specialist = await ctx.helper({ name: "route-specialist", sources: { "classify-task": state.classify }, options: { phase } });
|
|
44
|
+
const impl = await ctx.agent({ id: `pipeline.implementation.${phaseId}.impl.a${attempt}`, agent: "implementer", prompt: buildImplementPrompt(setup, state.classify ?? null, phase, specialist.value, state.spec ?? null) });
|
|
45
|
+
for (const f of ((impl.control as { filesModified?: unknown } | null)?.filesModified as string[] | undefined) ?? []) {
|
|
46
|
+
if (!filesModified.includes(f)) filesModified.push(f);
|
|
47
|
+
}
|
|
48
|
+
const qa = await ctx.agent({ id: `pipeline.implementation.${phaseId}.qa.a${attempt}`, agent: "qa-agent", prompt: buildQaPrompt(setup, state.classify ?? null, phase) });
|
|
49
|
+
const qaControl: ControlObj = qa.control ?? {};
|
|
50
|
+
const gate = await ctx.helper({ name: "gate-build", sources: { "qa-check": qaControl } });
|
|
51
|
+
if (gate.value.pass) {
|
|
52
|
+
green = true;
|
|
53
|
+
ctx.log(`Implementation ${phaseId} GREEN on attempt ${attempt}`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
ctx.log(`Implementation ${phaseId} attempt ${attempt}/${MAX_ATTEMPTS} FAIL: ${((gate.value.errors as string[]) ?? []).join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
if (!green) {
|
|
59
|
+
ctx.log(`Implementation ${phaseId} failed after ${MAX_ATTEMPTS} attempts — terminating early`);
|
|
60
|
+
allGreen = false;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
phasesCompleted++;
|
|
64
|
+
if (ctx.budget.check()) {
|
|
65
|
+
await ctx.agent({ id: `pipeline.implementation.${phaseId}.commit`, agent: "orchestrator", prompt: buildCommitPrompt(setup, phase.name) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const control: ControlObj = {
|
|
69
|
+
phasesCompleted,
|
|
70
|
+
totalPhases: phases.length,
|
|
71
|
+
allGreen,
|
|
72
|
+
filesModified,
|
|
73
|
+
summary: allGreen ? `All ${phases.length} phases completed successfully` : `${phasesCompleted}/${phases.length} phases completed`,
|
|
74
|
+
};
|
|
75
|
+
if (ctx.budget.check()) {
|
|
76
|
+
await ctx.agent({ id: "pipeline.implementation.summary", agent: "orchestrator", prompt: buildImplementationSummaryPrompt(setup, state.classify ?? null, control) });
|
|
77
|
+
}
|
|
78
|
+
return control;
|
|
79
|
+
},
|
|
80
|
+
};
|