@ghyper9023/pi-dev-workflow 0.2.0 → 0.3.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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * grill-me-agent.ts — 设计评审 (Grill) 和 PRD 生成的独立管理器
3
+ *
4
+ * 职责:
5
+ * 1. runGrillPhase() — 启动 sub-agent 生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
6
+ * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 pi-dev-output/pi-prd/
7
+ *
8
+ * 关键设计决策(修复 #2):
9
+ * sub-agent 通过 `write` 工具将评审问题写入临时文件,主进程事后读取。
10
+ * 不依赖从 NDJSON 响应文本中解析 JSON(多轮 tool-calling 场景不可靠)。
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
17
+ import { spawnSubagent, extractFinalOutput, type AgentDef } from "./sub-agents";
18
+ export type { AgentDef };
19
+ import {
20
+ Container,
21
+ SelectList,
22
+ Text,
23
+ Spacer,
24
+ type SelectItem,
25
+ } from "@earendil-works/pi-tui";
26
+
27
+ // ── Types ────────────────────────────────────────────────────
28
+
29
+ /** A single grill question returned by the sub-agent. */
30
+ export interface GrillQuestion {
31
+ id: number;
32
+ question: string;
33
+ options: string[];
34
+ }
35
+
36
+ /** Result of the grill phase. */
37
+ export interface GrillResult {
38
+ /** Whether the user cancelled. */
39
+ cancelled: boolean;
40
+ /** The Q&A pairs collected. */
41
+ pairs: Array<{ question: string; answer: string }>;
42
+ /** Final enriched prompt (original prompt + all Q&A). */
43
+ enhancedPrompt: string;
44
+ }
45
+
46
+ /** Result of the PRD phase. */
47
+ export interface PRDResult {
48
+ /** PRD content (Markdown). */
49
+ content: string;
50
+ /** Saved file path (relative to cwd). */
51
+ filePath: string;
52
+ }
53
+
54
+ // ── Output dirs ──────────────────────────────────────────────
55
+
56
+ const DEV_OUTPUT_DIR = "pi-dev-output";
57
+ const GRILL_DIRNAME = "pi-grill";
58
+ const PRD_DIRNAME = "pi-prd";
59
+
60
+ /** Ensure an output subdirectory exists and write .gitignore. */
61
+ function ensureOutputDir(cwd: string, subdir: string): string {
62
+ const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const gitignorePath = path.join(cwd, DEV_OUTPUT_DIR, ".gitignore");
65
+ try {
66
+ const existing = fs.readFileSync(gitignorePath, "utf-8").trim();
67
+ if (!existing.includes("*")) {
68
+ fs.writeFileSync(gitignorePath, "*\n!.gitignore\n");
69
+ }
70
+ } catch {
71
+ fs.writeFileSync(gitignorePath, "*\n!.gitignore\n");
72
+ }
73
+ return dir;
74
+ }
75
+
76
+ /** Generate a safe temp filename for grill output. */
77
+ function grillOutputPath(cwd: string): string {
78
+ const dir = ensureOutputDir(cwd, GRILL_DIRNAME);
79
+ const ts = Date.now().toString(36);
80
+ return path.join(dir, `questions-${ts}.json`);
81
+ }
82
+
83
+ /** Generate a safe PRD filename. */
84
+ function generatePrdFilename(moduleSuggestion: string): string {
85
+ const safe = moduleSuggestion
86
+ .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, "-")
87
+ .replace(/-+/g, "-")
88
+ .replace(/^-|-$/g, "")
89
+ .slice(0, 40);
90
+ const ts = new Date().toISOString().slice(0, 10).replace(/-/g, "");
91
+ return `${safe || "feature"}-${ts}.md`;
92
+ }
93
+
94
+ // ── Sub-agent definitions ────────────────────────────────────
95
+
96
+ /**
97
+ * Helper: build the system prompt suffix that tells the sub-agent to
98
+ * write results to a file via the `write` tool instead of putting JSON
99
+ * in its chat response.
100
+ */
101
+ function writeToolPromptSuffix(outputFilePath: string): string {
102
+ return [
103
+ "",
104
+ "## Output via `write` tool (CRITICAL)",
105
+ "",
106
+ "Do NOT output the questions JSON in your chat response.",
107
+ "Instead, use the `write` tool to save the questions to a file.",
108
+ `Write to this exact path: ${outputFilePath}`,
109
+ "",
110
+ "The file content must be a valid JSON object with a single key \"questions\":",
111
+ '```json',
112
+ '{',
113
+ ' "questions": [',
114
+ ' {',
115
+ ' "id": 1,',
116
+ ' "question": "问题文本?",',
117
+ ' "options": ["选项A", "选项B", "选项C"]',
118
+ ' }',
119
+ ' ]',
120
+ '}',
121
+ '```',
122
+ "",
123
+ "After writing, you may include a brief summary in your chat response.",
124
+ "But the JSON MUST be in the file, NOT in the chat.",
125
+ ].join("\n");
126
+ }
127
+
128
+ const GRILL_AGENT_DEF: AgentDef = {
129
+ name: "grill-agent",
130
+ description: "Design review agent — interviews the developer about a feature plan",
131
+ tools: ["read", "bash", "write"],
132
+ systemPrompt: [
133
+ "You are an expert design reviewer. Interview the developer relentlessly about every aspect of the feature plan until reaching shared understanding.",
134
+ "",
135
+ "## Rules",
136
+ "- For EACH question, provide recommended answer OPTIONS (a/b/c format) that the user can pick from",
137
+ "- Walk down each branch of the design tree, resolving dependencies between decisions one-by-one",
138
+ "- Be specific — refer to actual code modules, file paths, and architecture decisions",
139
+ "- Explore the codebase (use read/bash tools) when a question can be answered by looking at existing code",
140
+ "- Ask questions about: architecture, data flow, edge cases, security, testing, module boundaries, dependencies, error handling, performance, scalability",
141
+ "- If terminology conflicts with existing project glossary (CONTEXT.md), call it out",
142
+ "- Sharpen fuzzy language — propose precise canonical terms",
143
+ "- Stress-test scenarios with specific edge cases",
144
+ "- Cross-reference with existing code — surface contradictions",
145
+ "",
146
+ "## Quantity",
147
+ "Ask as many questions as needed to thoroughly review the design.",
148
+ "Do not artificially limit the number — cover architecture, data flow, edge cases, security, testing, module boundaries, dependencies, error handling, and more.",
149
+ "Typically 15-40 questions for a moderate feature.",
150
+ "",
151
+ "## Language",
152
+ "Questions and options should be in the same language as the feature request (default: Chinese).",
153
+ ].join("\n"),
154
+ timeoutMs: 300_000, // 5 min
155
+ };
156
+
157
+ const PRD_AGENT_DEF: AgentDef = {
158
+ name: "prd-agent",
159
+ description: "PRD writer — synthesizes a PRD from conversation context",
160
+ tools: ["read", "bash"],
161
+ systemPrompt: [
162
+ "You are an expert product spec writer.",
163
+ "Your task is to create a PRD from the provided conversation context.",
164
+ "",
165
+ "## Rules",
166
+ "- Do NOT ask any questions — just synthesize what you already know",
167
+ "- Explore the repo to understand the current state of the codebase",
168
+ "- Use the project's domain vocabulary throughout",
169
+ "- Use the template below and output ONLY the Markdown content (no JSON wrapper, no preamble)",
170
+ "",
171
+ "## Template",
172
+ "",
173
+ "# {Feature Name} — PRD",
174
+ "",
175
+ "## Problem Statement",
176
+ "The problem that the user is facing, from the user's perspective.",
177
+ "",
178
+ "## Solution",
179
+ "The solution to the problem, from the user's perspective.",
180
+ "",
181
+ "## User Stories",
182
+ "A numbered list of user stories:",
183
+ "1. As an <actor>, I want a <feature>, so that <benefit>",
184
+ "",
185
+ "## Implementation Decisions",
186
+ "A list of implementation decisions including modules to build/modify, architectural decisions, schema changes, API contracts.",
187
+ "Do NOT include specific file paths or code snippets (may become outdated).",
188
+ "",
189
+ "## Testing Decisions",
190
+ "A description of what makes a good test, which modules will be tested, prior art.",
191
+ "",
192
+ "## Out of Scope",
193
+ "Things explicitly out of scope.",
194
+ "",
195
+ "## Further Notes",
196
+ "Any further notes about the feature.",
197
+ ].join("\n"),
198
+ timeoutMs: 300_000,
199
+ };
200
+
201
+ // ── File-based question extraction ───────────────────────────
202
+
203
+ /**
204
+ * Try to read and parse questions from the output file that the sub-agent
205
+ * was instructed to write via the `write` tool.
206
+ */
207
+ function readQuestionsFromFile(filePath: string): GrillQuestion[] {
208
+ try {
209
+ if (!fs.existsSync(filePath)) return [];
210
+ const raw = fs.readFileSync(filePath, "utf-8").trim();
211
+ if (!raw) return [];
212
+ const parsed = JSON.parse(raw);
213
+ // Support both { questions: [...] } and raw [...]
214
+ const items = Array.isArray(parsed) ? parsed : parsed.questions;
215
+ if (!Array.isArray(items)) return [];
216
+ return items
217
+ .filter((q: any) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0)
218
+ .map((q: any, i: number) => ({
219
+ id: q.id ?? i + 1,
220
+ question: q.question,
221
+ options: q.options,
222
+ }));
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Parse sub-agent output into a list of GrillQuestions.
230
+ * Used as fallback when the file-based approach didn't produce results.
231
+ */
232
+ export function parseGrillQuestions(raw: string): GrillQuestion[] {
233
+ if (!raw || !raw.trim()) return [];
234
+
235
+ // Strategy 1: find any {} JSON, deep-search for "questions" key
236
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
237
+ if (jsonMatch) {
238
+ const candidate = jsonMatch[0]
239
+ .replace(/^\s*```(?:json)?\s*/, "")
240
+ .replace(/\s*```\s*$/, "");
241
+ try {
242
+ const parsed = JSON.parse(candidate);
243
+ const found = deepFindQuestions(parsed);
244
+ if (found && found.length > 0) {
245
+ return found
246
+ .filter((q) => q.question && q.options && q.options.length > 0)
247
+ .map((q, i) => ({
248
+ id: q.id ?? i + 1,
249
+ question: q.question!,
250
+ options: q.options!,
251
+ }));
252
+ }
253
+ } catch {
254
+ // fall through
255
+ }
256
+ }
257
+
258
+ // Strategy 2: try to find a question-like JSON array directly
259
+ const direct = extractQuestionArray(raw);
260
+ if (direct.length > 0) {
261
+ return direct.map((q, i) => ({ id: i + 1, question: q.question, options: q.options }));
262
+ }
263
+
264
+ // Strategy 3: extract from markdown code block
265
+ const codeBlock = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
266
+ if (codeBlock) {
267
+ return parseGrillQuestions(codeBlock[1]);
268
+ }
269
+
270
+ return [];
271
+ }
272
+
273
+ function deepFindQuestions(obj: unknown): Array<{ id?: number; question?: string; options?: string[] }> | null {
274
+ if (!obj || typeof obj !== "object") return null;
275
+ if (Array.isArray(obj)) {
276
+ for (const item of obj) {
277
+ const found = deepFindQuestions(item);
278
+ if (found) return found;
279
+ }
280
+ return null;
281
+ }
282
+ const record = obj as Record<string, unknown>;
283
+ if ("questions" in record && Array.isArray(record.questions)) {
284
+ return record.questions as Array<{ id?: number; question?: string; options?: string[] }>;
285
+ }
286
+ for (const val of Object.values(record)) {
287
+ const found = deepFindQuestions(val);
288
+ if (found) return found;
289
+ }
290
+ return null;
291
+ }
292
+
293
+ function extractQuestionArray(raw: string): Array<{ question: string; options: string[] }> {
294
+ const arrayMatch = raw.match(/\[\s*\{[\s\S]*?"question"[\s\S]*?"options"[\s\S]*?\}\s*\]/);
295
+ if (!arrayMatch) return [];
296
+ try {
297
+ const items = JSON.parse(arrayMatch[0]);
298
+ if (!Array.isArray(items)) return [];
299
+ return items.filter((item: unknown): item is { question: string; options: string[] } => {
300
+ if (!item || typeof item !== "object") return false;
301
+ const r = item as Record<string, unknown>;
302
+ return typeof r.question === "string" && Array.isArray(r.options) && r.options.length > 0;
303
+ });
304
+ } catch {
305
+ return [];
306
+ }
307
+ }
308
+
309
+ // ── Grill Phase ──────────────────────────────────────────────
310
+
311
+ /**
312
+ * Options for customizing the grill phase.
313
+ */
314
+ export interface GrillOptions {
315
+ agentDef?: AgentDef;
316
+ title?: string;
317
+ description?: string;
318
+ questionTitle?: string;
319
+ loaderLabel?: string;
320
+ }
321
+
322
+ /**
323
+ * Run the grill phase:
324
+ * 1. Confirm with user
325
+ * 2. Call sub-agent → sub-agent writes questions to a file via `write` tool
326
+ * 3. Read questions from the file
327
+ * 4. Show each question as TUI SelectList + custom input option
328
+ * 5. Collect all Q&A pairs
329
+ * 6. Return enhanced prompt
330
+ */
331
+ export async function runGrillPhase(
332
+ assembledPrompt: string,
333
+ ctx: ExtensionCommandContext,
334
+ options?: GrillOptions,
335
+ ): Promise<GrillResult> {
336
+ const defaultResult: GrillResult = {
337
+ cancelled: false,
338
+ pairs: [],
339
+ enhancedPrompt: assembledPrompt,
340
+ };
341
+
342
+ const agentDef = options?.agentDef ?? GRILL_AGENT_DEF;
343
+ const confirmTitle = options?.title ?? "🔍 设计方案评审";
344
+ const confirmDesc = options?.description ?? "是否进入设计评审 (Grill) 模式?\nAI 会从架构、数据流、边界条件、安全等多个维度挑战你的设计。";
345
+ const qTitlePrefix = options?.questionTitle ?? "设计方案评审";
346
+ const loaderLabel = options?.loaderLabel ?? "🧠 AI 正在分析代码并生成评审问题...";
347
+
348
+ // ── Step 1: Confirm entering grill mode ──────────────────
349
+ const enterGrill = await ctx.ui.confirm(confirmTitle, confirmDesc);
350
+ if (!enterGrill) {
351
+ return { ...defaultResult, cancelled: true };
352
+ }
353
+
354
+ // ── Step 2: Prepare output file + enhanced prompt ─────────
355
+ const outputFilePath = grillOutputPath(ctx.cwd);
356
+ const enhancedPrompt = [
357
+ assembledPrompt,
358
+ writeToolPromptSuffix(outputFilePath),
359
+ ].join("\n\n");
360
+
361
+ // ── Step 3: Call sub-agent with BorderedLoader ───────────
362
+ const questions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
363
+ const loader = new BorderedLoader(tui, theme, loaderLabel);
364
+ loader.onAbort = () => done([]);
365
+
366
+ spawnSubagent(
367
+ agentDef,
368
+ enhancedPrompt,
369
+ ctx.cwd,
370
+ loader.signal,
371
+ undefined,
372
+ (progress) => { loader.setText(`🧠 ${progress.slice(0, 60)}`); },
373
+ )
374
+ .then((result) => {
375
+ // Primary: read from file (sub-agent wrote via `write` tool)
376
+ let qs = readQuestionsFromFile(outputFilePath);
377
+ // Fallback: parse from NDJSON response text
378
+ if (qs.length === 0) {
379
+ const output = extractFinalOutput(result.output);
380
+ qs = parseGrillQuestions(output);
381
+ }
382
+ done(qs);
383
+ })
384
+ .catch(() => {
385
+ // On error, still try to read file (sub-agent may have written before error)
386
+ const qs = readQuestionsFromFile(outputFilePath);
387
+ done(qs);
388
+ });
389
+
390
+ return loader;
391
+ });
392
+
393
+ // ── Step 4: Clean up temp file ───────────────────────────
394
+ try { fs.unlinkSync(outputFilePath); } catch { /* ignore */ }
395
+
396
+ // ── Step 4b: Retry dialog if no questions generated ──────
397
+ if (questions.length === 0) {
398
+ const choice = await ctx.ui.select(
399
+ "⚠️ AI 未能成功生成评审问题",
400
+ [
401
+ "🔄 重新尝试生成评审问题",
402
+ "⏭️ 跳过 Grill,直接发送 Prompt",
403
+ "❌ 取消 (Esc)",
404
+ ],
405
+ );
406
+
407
+ switch (choice) {
408
+ case "🔄 重新尝试生成评审问题": {
409
+ const retryPath = grillOutputPath(ctx.cwd);
410
+ const retryPrompt = [assembledPrompt, writeToolPromptSuffix(retryPath)].join("\n\n");
411
+ questions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
412
+ const loader = new BorderedLoader(tui, theme, loaderLabel);
413
+ loader.onAbort = () => done([]);
414
+ spawnSubagent(agentDef, retryPrompt, ctx.cwd, loader.signal, undefined)
415
+ .then((r) => {
416
+ let qs = readQuestionsFromFile(retryPath);
417
+ if (qs.length === 0) qs = parseGrillQuestions(extractFinalOutput(r.output));
418
+ done(qs);
419
+ })
420
+ .catch(() => done([]));
421
+ return loader;
422
+ });
423
+ try { fs.unlinkSync(retryPath); } catch { /* ignore */ }
424
+ if (questions.length === 0) {
425
+ ctx.ui.notify("⚠️ 再次尝试仍然失败,跳过 Grill 阶段", "warning");
426
+ return defaultResult;
427
+ }
428
+ break;
429
+ }
430
+ case "⏭️ 跳过 Grill,直接发送 Prompt":
431
+ ctx.ui.notify("⏭️ 已跳过 Grill 阶段", "info");
432
+ return defaultResult;
433
+ case "❌ 取消 (Esc)":
434
+ default:
435
+ ctx.ui.notify("❌ 操作已取消", "warning");
436
+ return { ...defaultResult, cancelled: true };
437
+ }
438
+ }
439
+
440
+ ctx.ui.notify(`✅ AI 生成了 ${questions.length} 个评审问题`, "success");
441
+
442
+ // ── Step 5: TUI — present questions one by one ───────────
443
+ const pairs: Array<{ question: string; answer: string }> = [];
444
+
445
+ for (let idx = 0; idx < questions.length; idx++) {
446
+ const q = questions[idx];
447
+ const answer = await showQuestionTUI(ctx, q, idx + 1, questions.length, qTitlePrefix);
448
+
449
+ if (answer === null) {
450
+ ctx.ui.notify("❌ 评审已取消", "warning");
451
+ return { ...defaultResult, cancelled: true, pairs };
452
+ }
453
+
454
+ pairs.push({ question: q.question, answer });
455
+ }
456
+
457
+ // ── Step 6: Assemble enhanced prompt ─────────────────────
458
+ const qaBlock = pairs
459
+ .map((p, i) => `[评审问题 ${i + 1}]\n问题: ${p.question}\n回答: ${p.answer}`)
460
+ .join("\n\n");
461
+
462
+ const finalEnhancedPrompt = [
463
+ assembledPrompt,
464
+ "",
465
+ "---",
466
+ "## 设计评审记录",
467
+ "",
468
+ "以下是在开发前进行的设计评审问答,所有决策已确认:",
469
+ "",
470
+ qaBlock,
471
+ ].join("\n");
472
+
473
+ ctx.ui.notify(`✅ 评审完成,共 ${pairs.length} 道问题`, "success");
474
+
475
+ return {
476
+ cancelled: false,
477
+ pairs,
478
+ enhancedPrompt: finalEnhancedPrompt,
479
+ };
480
+ }
481
+
482
+ /** Show a single grill question as TUI SelectList + custom input option. */
483
+ async function showQuestionTUI(
484
+ ctx: ExtensionCommandContext,
485
+ q: GrillQuestion,
486
+ currentIndex: number,
487
+ totalCount: number,
488
+ titlePrefix = "设计方案评审",
489
+ ): Promise<string | null> {
490
+ const selectItems: SelectItem[] = q.options.map((opt, i) => ({
491
+ value: `opt-${i}`,
492
+ label: `(${String.fromCharCode(97 + i)}) ${opt}`,
493
+ }));
494
+ selectItems.push({
495
+ value: "__custom__",
496
+ label: "✏️ 自定义输入",
497
+ description: "输入你自己的回答,不受选项限制",
498
+ });
499
+
500
+ const title = `${titlePrefix} (问题 ${currentIndex}/${totalCount})`;
501
+
502
+ const value = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
503
+ const container = new Container();
504
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
505
+ container.addChild(new Text(theme.fg("accent", theme.bold(` ${title}`)), 0, 0));
506
+ container.addChild(new Spacer(1));
507
+ container.addChild(new Text(theme.fg("text", ` ${q.question}`), 0, 0));
508
+ container.addChild(new Spacer(1));
509
+
510
+ const visibleCount = Math.min(selectItems.length + 1, 12);
511
+ const selectList = new SelectList(selectItems, visibleCount, {
512
+ selectedPrefix: (s) => theme.fg("accent", s),
513
+ selectedText: (s) => theme.fg("accent", s),
514
+ description: (s) => theme.fg("muted", s),
515
+ scrollInfo: (s) => theme.fg("dim", s),
516
+ noMatch: (s) => theme.fg("warning", s),
517
+ });
518
+ selectList.onSelect = (item) => done(item.value);
519
+ selectList.onCancel = () => done(null);
520
+ container.addChild(selectList);
521
+
522
+ container.addChild(new Spacer(1));
523
+ container.addChild(
524
+ new Text(theme.fg("dim", " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审"), 0, 0),
525
+ );
526
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
527
+
528
+ return {
529
+ render: (w) => container.render(w),
530
+ invalidate: () => container.invalidate(),
531
+ handleInput: (data) => {
532
+ selectList.handleInput(data);
533
+ tui.requestRender();
534
+ },
535
+ };
536
+ });
537
+
538
+ if (value === null) return null;
539
+ if (value === "__custom__") {
540
+ const custom = await ctx.ui.input("✏️ 自定义回答", {
541
+ placeholder: "输入你的回答内容(Esc 取消本题,回到选项)",
542
+ required: false,
543
+ });
544
+ if (custom === undefined) return showQuestionTUI(ctx, q, currentIndex, totalCount);
545
+ return custom.trim() || "(空)";
546
+ }
547
+
548
+ const optIndex = parseInt(value.replace("opt-", ""), 10);
549
+ return q.options[optIndex] || value;
550
+ }
551
+
552
+ // ── PRD Phase ────────────────────────────────────────────────
553
+
554
+ /**
555
+ * Run the PRD phase:
556
+ * 1. Ask user if they want to create a PRD
557
+ * 2. Call sub-agent → gets PRD Markdown
558
+ * 3. Save to pi-dev-output/pi-prd/<name>.md
559
+ * 4. Ask if user wants to start development
560
+ */
561
+ export async function runPRDPhase(
562
+ context: string,
563
+ moduleHint: string,
564
+ pi: ExtensionAPI,
565
+ ctx: ExtensionCommandContext,
566
+ ): Promise<PRDResult | null> {
567
+ const wantPrd = await ctx.ui.confirm(
568
+ "📋 创建 PRD",
569
+ "是否为此功能创建 PRD 文档?\nPRD 将保存到 pi-dev-output/pi-prd/ 目录。",
570
+ );
571
+ if (!wantPrd) return null;
572
+
573
+ const prdContent = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
574
+ const loader = new BorderedLoader(tui, theme, "📝 AI 正在生成 PRD 文档...");
575
+ loader.onAbort = () => done(null);
576
+
577
+ const prdTask = [
578
+ "请根据以下上下文生成一份 PRD(产品需求文档)。",
579
+ "输出格式必须是完整的 Markdown 文档。",
580
+ "",
581
+ "=== 上下文 ===",
582
+ context,
583
+ ].join("\n");
584
+
585
+ spawnSubagent(PRD_AGENT_DEF, prdTask, ctx.cwd, loader.signal)
586
+ .then((result) => {
587
+ const output = extractFinalOutput(result.output);
588
+ done(output && output.length >= 50 ? output : null);
589
+ })
590
+ .catch(() => done(null));
591
+
592
+ return loader;
593
+ });
594
+
595
+ if (!prdContent) {
596
+ ctx.ui.notify("⚠️ PRD 生成失败", "error");
597
+ return null;
598
+ }
599
+
600
+ const prdDir = ensureOutputDir(ctx.cwd, PRD_DIRNAME);
601
+ const filename = generatePrdFilename(moduleHint);
602
+ const filePath = path.join(DEV_OUTPUT_DIR, PRD_DIRNAME, filename);
603
+ const fullPath = path.join(prdDir, filename);
604
+ fs.writeFileSync(fullPath, prdContent, "utf-8");
605
+ ctx.ui.notify(`✅ PRD 已保存到 ${filePath}`, "success");
606
+
607
+ await askDevelopmentStart(pi, ctx, prdContent, filePath);
608
+ return { content: prdContent, filePath };
609
+ }
610
+
611
+ async function askDevelopmentStart(
612
+ pi: ExtensionAPI,
613
+ ctx: ExtensionCommandContext,
614
+ prdContent: string,
615
+ prdFilePath: string,
616
+ ): Promise<void> {
617
+ const choice = await ctx.ui.select(
618
+ "🚀 是否开始开发?",
619
+ [
620
+ "是 — 根据 PRD 开始开发",
621
+ "否 — 稍后手动开始",
622
+ "✏️ 自定义开发指令",
623
+ ],
624
+ );
625
+ if (!choice) return;
626
+
627
+ switch (choice) {
628
+ case "是 — 根据 PRD 开始开发": {
629
+ const devMsg = [
630
+ "请根据以下 PRD 文档开始开发:",
631
+ "",
632
+ `PRD 文件: \`${prdFilePath}\``,
633
+ "",
634
+ "--- PRD 全文 ---",
635
+ prdContent,
636
+ "",
637
+ "---",
638
+ "",
639
+ "请按照上述 PRD 逐步实现。先分析代码库结构,给出实施计划,确认后再编写代码。",
640
+ ].join("\n");
641
+ pi.sendUserMessage(devMsg);
642
+ ctx.ui.notify("🚀 已发送开发指令给主代理", "success");
643
+ break;
644
+ }
645
+ case "否 — 稍后手动开始":
646
+ ctx.ui.notify(`📋 PRD 已保存在 ${prdFilePath},可随时手动引用`, "info");
647
+ break;
648
+ default: {
649
+ const customMsg = await ctx.ui.input("✏️ 自定义开发指令", {
650
+ placeholder: "输入你的开发指令(将结合 PRD 一起发送给主代理)",
651
+ required: false,
652
+ });
653
+ if (customMsg === undefined) return askDevelopmentStart(pi, ctx, prdContent, prdFilePath);
654
+ const finalMsg = customMsg.trim()
655
+ ? [
656
+ `自定义开发指令: ${customMsg.trim()}`,
657
+ "",
658
+ `PRD 文件: \`${prdFilePath}\``,
659
+ "",
660
+ "--- PRD 全文 ---",
661
+ prdContent,
662
+ ].join("\n")
663
+ : [
664
+ "请根据以下 PRD 文档开始开发:",
665
+ "",
666
+ `PRD 文件: \`${prdFilePath}\``,
667
+ "",
668
+ "--- PRD 全文 ---",
669
+ prdContent,
670
+ ].join("\n");
671
+ pi.sendUserMessage(finalMsg);
672
+ ctx.ui.notify("🚀 已发送自定义开发指令给主代理", "success");
673
+ break;
674
+ }
675
+ }
676
+ }
677
+
678
+ // ── Extension factory (required by pi extension loader) ─────
679
+ //
680
+ // This file lives in extensions/ so pi will attempt to load it.
681
+ // The default export satisfies the loader; the real functionality
682
+ // is consumed by dev-prompts.ts via named imports.
683
+ //
684
+ export default function (_pi: ExtensionAPI) {
685
+ // grill-me-agent is a helper module, not a standalone extension.
686
+ // It is imported by dev-prompts.ts to provide grill + PRD phases.
687
+ }