@ghyper9023/pi-dev-workflow 0.3.1 → 0.4.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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 职责:
5
5
  * 1. runGrillPhase() — 启动 sub-agent 生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
6
- * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 pi-dev-output/pi-prd/
6
+ * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 .pi-dev-output/pi-prd/
7
7
  *
8
8
  * 关键设计决策(修复 #2):
9
9
  * sub-agent 通过 `write` 工具将评审问题写入临时文件,主进程事后读取。
@@ -14,7 +14,7 @@ import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
16
  import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
17
- import { spawnSubagent, extractFinalOutput, type AgentDef } from "./sub-agents";
17
+ import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef } from "./sub-agents";
18
18
  export type { AgentDef };
19
19
  import {
20
20
  Container,
@@ -23,6 +23,7 @@ import {
23
23
  Spacer,
24
24
  type SelectItem,
25
25
  } from "@earendil-works/pi-tui";
26
+ import { uiSelect, uiConfirm, uiInput } from "./ui-helpers";
26
27
 
27
28
  // ── Types ────────────────────────────────────────────────────
28
29
 
@@ -53,31 +54,67 @@ export interface PRDResult {
53
54
 
54
55
  // ── Output dirs ──────────────────────────────────────────────
55
56
 
56
- const DEV_OUTPUT_DIR = "pi-dev-output";
57
+ const DEV_OUTPUT_DIR = ".pi-dev-output";
57
58
  const GRILL_DIRNAME = "pi-grill";
59
+ const GRILL_ANSWERS_DIRNAME = "answers";
60
+ const GRILL_QUESTIONS_DIRNAME = "questions";
58
61
  const PRD_DIRNAME = "pi-prd";
59
62
 
60
- /** Ensure an output subdirectory exists and write .gitignore. */
63
+ /** Ensure an output subdirectory exists. */
61
64
  function ensureOutputDir(cwd: string, subdir: string): string {
62
65
  const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
63
66
  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
67
  return dir;
74
68
  }
75
69
 
76
- /** Generate a safe temp filename for grill output. */
70
+ /** Format current time as YYYYMMDD-HHmm for human-readable timestamps. */
71
+ function formatTimestamp(): string {
72
+ const now = new Date();
73
+ const Y = now.getFullYear().toString();
74
+ const M = (now.getMonth() + 1).toString().padStart(2, "0");
75
+ const D = now.getDate().toString().padStart(2, "0");
76
+ const h = now.getHours().toString().padStart(2, "0");
77
+ const m = now.getMinutes().toString().padStart(2, "0");
78
+ return `${Y}${M}${D}-${h}${m}`;
79
+ }
80
+
81
+ /** Generate a safe temp filename for grill output (pi-grill/questions/questions-<id>-<YYYYMMDD-HHmm>.json). */
77
82
  function grillOutputPath(cwd: string): string {
78
- const dir = ensureOutputDir(cwd, GRILL_DIRNAME);
83
+ const dir = ensureOutputDir(cwd, path.join(GRILL_DIRNAME, GRILL_QUESTIONS_DIRNAME));
79
84
  const ts = Date.now().toString(36);
80
- return path.join(dir, `questions-${ts}.json`);
85
+ return path.join(dir, `questions-${ts}-${formatTimestamp()}.json`);
86
+ }
87
+
88
+ /**
89
+ * Save the final assembled prompt to a timestamped answer file (pi-grill/answers/answer-<id>-<YYYYMMDD-HHmm>.md).
90
+ * Returns the relative path from cwd (for display in notifications).
91
+ */
92
+ export function saveAnswerFile(cwd: string, content: string): string {
93
+ const dir = ensureOutputDir(cwd, path.join(GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME));
94
+ const ts = Date.now().toString(36);
95
+ const filename = `answer-${ts}-${formatTimestamp()}.md`;
96
+ fs.writeFileSync(path.join(dir, filename), content, "utf-8");
97
+ return path.join(DEV_OUTPUT_DIR, GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME, filename);
98
+ }
99
+
100
+ /**
101
+ * Find the most recent answer backup file and read its content.
102
+ * Returns undefined if no backup exists or read fails.
103
+ * Now reads from pi-grill/answers/ subdirectory.
104
+ */
105
+ export function recoverFromBackup(cwd: string): string | undefined {
106
+ const dir = path.join(cwd, DEV_OUTPUT_DIR, GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME);
107
+ try {
108
+ if (!fs.existsSync(dir)) return undefined;
109
+ const files = fs.readdirSync(dir)
110
+ .filter(f => f.startsWith("answer-") && f.endsWith(".md"))
111
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
112
+ .sort((a, b) => b.mtime - a.mtime);
113
+ if (files.length === 0) return undefined;
114
+ return fs.readFileSync(path.join(dir, files[0].name), "utf-8");
115
+ } catch {
116
+ return undefined;
117
+ }
81
118
  }
82
119
 
83
120
  /** Generate a safe PRD filename. */
@@ -107,96 +144,81 @@ function writeToolPromptSuffix(outputFilePath: string): string {
107
144
  "Instead, use the `write` tool to save the questions to a file.",
108
145
  `Write to this exact path: ${outputFilePath}`,
109
146
  "",
110
- "The file content must be a valid JSON object with a single key \"questions\":",
111
- '```json',
147
+ "The file content must be a **valid** JSON object conforming to this JSON Schema (Draft-07):",
148
+ "",
149
+ "```json",
150
+ '{',
151
+ ' "$schema": "http://json-schema.org/draft-07/schema#",',
152
+ ' "type": "object",',
153
+ ' "required": ["questions"],',
154
+ ' "properties": {',
155
+ ' "questions": {',
156
+ ' "type": "array",',
157
+ ' "items": {',
158
+ ' "type": "object",',
159
+ ' "required": ["id", "question", "options"],',
160
+ ' "properties": {',
161
+ ' "id": { "type": "integer" },',
162
+ ' "question": { "type": "string" },',
163
+ ' "options": {',
164
+ ' "type": "array",',
165
+ ' "items": { "type": "string" },',
166
+ ' "minItems": 1',
167
+ ' }',
168
+ ' }',
169
+ ' }',
170
+ ' }',
171
+ ' }',
172
+ '}',
173
+ "```",
174
+ "",
175
+ "### \u26a0\ufe0f CRITICAL: String escaping rules",
176
+ "",
177
+ "Every string value (question text, option text) MUST be valid JSON-escaped:",
178
+ "- Double quotes inside text \u2192 \\\"",
179
+ "- Newlines \u2192 \\n",
180
+ "- Backslashes \u2192 \\\\",
181
+ "- Tabs \u2192 \\t",
182
+ "",
183
+ "### \u2705 Self-review before writing",
184
+ "",
185
+ "Before calling the `write` tool, mentally validate your JSON.",
186
+ "Check that all strings are properly escaped and the structure matches the schema above.",
187
+ "If you are unsure, write a quick test with `bash` (e.g. `node -e \"JSON.parse(...)\"`).",
188
+ "",
189
+ "Example output:",
190
+ "```json",
112
191
  '{',
113
192
  ' "questions": [',
114
193
  ' {',
115
194
  ' "id": 1,',
116
- ' "question": "问题文本?",',
117
- ' "options": ["选项A", "选项B", "选项C"]',
195
+ ' "question": "\\u9879\\u76ee\\u662f\\u5426\\u5b58\\u5728\\u6a21\\u5757\\u7ed3\\u6784\\uff1f",',
196
+ ' "options": [',
197
+ ' "\\u5df2\\u5b58\\u5728\\uff0c\\u4f8b\\u5982 src/controller/example.rs",',
198
+ ' "\\u4ece\\u96f6\\u521b\\u5efa"',
199
+ ' ]',
200
+ ' },',
201
+ ' {',
202
+ ' "id": 2,',
203
+ ' "question": "\\u4ed6\\u8bf4\\u201c\\u8fd9\\u4e2a\\u4e0d\\u884c\\u201d\\uff0c\\u8be5\\u5982\\u4f55\\u5904\\u7406\\uff1f",',
204
+ ' "options": [',
205
+ ' "\\u5ffd\\u7565",',
206
+ ' "\\u4fee\\u590d"',
207
+ ' ]',
118
208
  ' }',
119
209
  ' ]',
120
210
  '}',
121
- '```',
211
+ "```",
122
212
  "",
123
213
  "After writing, you may include a brief summary in your chat response.",
124
214
  "But the JSON MUST be in the file, NOT in the chat.",
125
215
  ].join("\n");
126
216
  }
127
217
 
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
- };
218
+ // ── Default agent definitions (loaded from agents/ directory) ────
219
+
220
+ const _defaultGrillAgent = discoverAgents().find(a => a.name === "dev-grill-agent")!;
221
+ const _defaultPrdAgent = discoverAgents().find(a => a.name === "dev-prd-agent")!;
200
222
 
201
223
  // ── File-based question extraction ───────────────────────────
202
224
 
@@ -220,7 +242,13 @@ function readQuestionsFromFile(filePath: string): GrillQuestion[] {
220
242
  question: q.question,
221
243
  options: q.options,
222
244
  }));
223
- } catch {
245
+ } catch (e) {
246
+ // Log parse errors for debugging (development feedback)
247
+ try {
248
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8").slice(0, 1000) : "(file not found)";
249
+ console.error(`[grill-me-agent] JSON parse error in ${filePath}:`, (e as Error).message);
250
+ console.error(`[grill-me-agent] First 1000 chars:`, content);
251
+ } catch { /* ignore secondary errors */ }
224
252
  return [];
225
253
  }
226
254
  }
@@ -339,16 +367,17 @@ export async function runGrillPhase(
339
367
  enhancedPrompt: assembledPrompt,
340
368
  };
341
369
 
342
- const agentDef = options?.agentDef ?? GRILL_AGENT_DEF;
370
+ const agentDef = options?.agentDef ?? _defaultGrillAgent;
343
371
  const confirmTitle = options?.title ?? "🔍 设计方案评审";
344
- const confirmDesc = options?.description ?? "是否进入设计评审 (Grill) 模式?\nAI 会从架构、数据流、边界条件、安全等多个维度挑战你的设计。";
372
+ const confirmDesc = options?.description ?? "AI 会从架构、数据流、边界条件、安全等多个维度挑战你的设计。";
345
373
  const qTitlePrefix = options?.questionTitle ?? "设计方案评审";
346
- const loaderLabel = options?.loaderLabel ?? "🧠 AI 正在分析代码并生成评审问题...";
374
+ const loaderLabel = options?.loaderLabel ?? "🧠 AI 子代理正在分析代码并生成评审问题...";
347
375
 
348
376
  // ── Step 1: Confirm entering grill mode ──────────────────
349
- const enterGrill = await ctx.ui.confirm(confirmTitle, confirmDesc);
377
+ const enterGrill = await uiConfirm(ctx, confirmTitle, confirmDesc);
350
378
  if (!enterGrill) {
351
- return { ...defaultResult, cancelled: true };
379
+ // Skip grill but continue the workflow (not a cancellation)
380
+ return defaultResult;
352
381
  }
353
382
 
354
383
  // ── Step 2: Prepare output file + enhanced prompt ─────────
@@ -369,12 +398,13 @@ export async function runGrillPhase(
369
398
  ctx.cwd,
370
399
  loader.signal,
371
400
  undefined,
372
- (progress) => { loader.setText(`🧠 ${progress.slice(0, 60)}`); },
401
+ (progress) => {
402
+ const inner = (loader as unknown as { loader?: { setText?: (t: string) => void } }).loader;
403
+ inner?.setText?.(`🧠 ${progress.slice(0, 60)}`);
404
+ },
373
405
  )
374
406
  .then((result) => {
375
- // Primary: read from file (sub-agent wrote via `write` tool)
376
407
  let qs = readQuestionsFromFile(outputFilePath);
377
- // Fallback: parse from NDJSON response text
378
408
  if (qs.length === 0) {
379
409
  const output = extractFinalOutput(result.output);
380
410
  qs = parseGrillQuestions(output);
@@ -382,7 +412,6 @@ export async function runGrillPhase(
382
412
  done(qs);
383
413
  })
384
414
  .catch(() => {
385
- // On error, still try to read file (sub-agent may have written before error)
386
415
  const qs = readQuestionsFromFile(outputFilePath);
387
416
  done(qs);
388
417
  });
@@ -390,12 +419,21 @@ export async function runGrillPhase(
390
419
  return loader;
391
420
  });
392
421
 
393
- // ── Step 4: Clean up temp file ───────────────────────────
394
- try { fs.unlinkSync(outputFilePath); } catch { /* ignore */ }
395
-
396
- // ── Step 4b: Retry dialog if no questions generated ──────
422
+ // ── Step 4: Retry dialog if no questions generated ──────
397
423
  if (questions.length === 0) {
398
- const choice = await ctx.ui.select(
424
+ let failedFileContent = "";
425
+ let parseErrorMsg = "";
426
+ try {
427
+ if (fs.existsSync(outputFilePath)) {
428
+ failedFileContent = fs.readFileSync(outputFilePath, "utf-8").slice(0, 2000);
429
+ JSON.parse(failedFileContent);
430
+ }
431
+ } catch (e) {
432
+ parseErrorMsg = (e as Error).message;
433
+ }
434
+
435
+ const choice = await uiSelect(
436
+ ctx,
399
437
  "⚠️ AI 未能成功生成评审问题",
400
438
  [
401
439
  "🔄 重新尝试生成评审问题",
@@ -407,8 +445,29 @@ export async function runGrillPhase(
407
445
  switch (choice) {
408
446
  case "🔄 重新尝试生成评审问题": {
409
447
  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) => {
448
+ const errorFeedback = parseErrorMsg
449
+ ? [
450
+ "",
451
+ "### ⚠️ Previous attempt had JSON errors — fix them now",
452
+ "",
453
+ `The previous attempt wrote to \`${outputFilePath}\` but the JSON was invalid.`,
454
+ "",
455
+ `JSON parse error: ${parseErrorMsg}`,
456
+ "",
457
+ "Invalid file content (first 2000 chars):",
458
+ "```",
459
+ failedFileContent.slice(0, 1000),
460
+ "```",
461
+ "",
462
+ "Please write valid JSON to the new path below. Make sure all strings are properly JSON-escaped.",
463
+ ].join("\n")
464
+ : "";
465
+ const retryPrompt = [
466
+ assembledPrompt,
467
+ writeToolPromptSuffix(retryPath),
468
+ errorFeedback,
469
+ ].filter(Boolean).join("\n\n");
470
+ const retryQuestions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
412
471
  const loader = new BorderedLoader(tui, theme, loaderLabel);
413
472
  loader.onAbort = () => done([]);
414
473
  spawnSubagent(agentDef, retryPrompt, ctx.cwd, loader.signal, undefined)
@@ -420,38 +479,91 @@ export async function runGrillPhase(
420
479
  .catch(() => done([]));
421
480
  return loader;
422
481
  });
423
- try { fs.unlinkSync(retryPath); } catch { /* ignore */ }
424
- if (questions.length === 0) {
425
- ctx.ui.notify("⚠️ 再次尝试仍然失败,跳过 Grill 阶段", "warning");
482
+ if (retryQuestions.length === 0) {
426
483
  return defaultResult;
427
484
  }
428
- break;
485
+ // Replace questions with retry results (with back support)
486
+ const pairs: Array<{ question: string; answer: string }> = [];
487
+ let rIdx = 0;
488
+ while (rIdx >= 0 && rIdx < retryQuestions.length) {
489
+ const q = retryQuestions[rIdx]!;
490
+ const previousAnswer = pairs[rIdx]?.answer;
491
+ const answer = await showQuestionTUI(ctx, q, rIdx + 1, retryQuestions.length, qTitlePrefix,
492
+ rIdx > 0, previousAnswer);
493
+ if (answer === null) {
494
+ return { ...defaultResult, cancelled: true, pairs };
495
+ }
496
+ if (answer === "__BACK__") {
497
+ if (rIdx > 0) {
498
+ rIdx--;
499
+ continue;
500
+ }
501
+ return { ...defaultResult, cancelled: true, pairs };
502
+ }
503
+ // Overwrite if re-answering, otherwise append
504
+ if (rIdx < pairs.length) {
505
+ pairs[rIdx] = { question: q.question, answer };
506
+ } else {
507
+ pairs.push({ question: q.question, answer });
508
+ }
509
+ rIdx++;
510
+ }
511
+ const qaBlock = pairs
512
+ .map((p, i) => `[评审问题 ${i + 1}]\n问题: ${p.question}\n回答: ${p.answer}`)
513
+ .join("\n\n");
514
+ const finalEnhancedPrompt = [
515
+ assembledPrompt,
516
+ "",
517
+ "---",
518
+ "## 设计评审记录",
519
+ "",
520
+ "以下是在开发前进行的设计评审问答,所有决策已确认:",
521
+ "",
522
+ qaBlock,
523
+ ].join("\n");
524
+ return {
525
+ cancelled: false,
526
+ pairs,
527
+ enhancedPrompt: finalEnhancedPrompt,
528
+ };
429
529
  }
430
530
  case "⏭️ 跳过 Grill,直接发送 Prompt":
431
- ctx.ui.notify("⏭️ 已跳过 Grill 阶段", "info");
432
531
  return defaultResult;
433
532
  case "❌ 取消 (Esc)":
434
533
  default:
435
- ctx.ui.notify("❌ 操作已取消", "warning");
436
534
  return { ...defaultResult, cancelled: true };
437
535
  }
438
536
  }
439
537
 
440
- ctx.ui.notify(`✅ AI 生成了 ${questions.length} 个评审问题`, "success");
441
-
442
- // ── Step 5: TUI — present questions one by one ───────────
538
+ // ── Step 5: TUI — present questions one by one (with back support) ──
443
539
  const pairs: Array<{ question: string; answer: string }> = [];
540
+ let qIdx = 0;
444
541
 
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);
542
+ while (qIdx >= 0 && qIdx < questions.length) {
543
+ const q = questions[qIdx]!;
544
+ const previousAnswer = pairs[qIdx]?.answer;
545
+ const answer = await showQuestionTUI(ctx, q, qIdx + 1, questions.length, qTitlePrefix,
546
+ qIdx > 0, previousAnswer);
448
547
 
449
548
  if (answer === null) {
450
- ctx.ui.notify("❌ 评审已取消", "warning");
451
549
  return { ...defaultResult, cancelled: true, pairs };
452
550
  }
453
551
 
454
- pairs.push({ question: q.question, answer });
552
+ if (answer === "__BACK__") {
553
+ if (qIdx > 0) {
554
+ qIdx--;
555
+ continue;
556
+ }
557
+ return { ...defaultResult, cancelled: true, pairs };
558
+ }
559
+
560
+ // Overwrite if re-answering (back then forward), otherwise append
561
+ if (qIdx < pairs.length) {
562
+ pairs[qIdx] = { question: q.question, answer };
563
+ } else {
564
+ pairs.push({ question: q.question, answer });
565
+ }
566
+ qIdx++;
455
567
  }
456
568
 
457
569
  // ── Step 6: Assemble enhanced prompt ─────────────────────
@@ -470,8 +582,6 @@ export async function runGrillPhase(
470
582
  qaBlock,
471
583
  ].join("\n");
472
584
 
473
- ctx.ui.notify(`✅ 评审完成,共 ${pairs.length} 道问题`, "success");
474
-
475
585
  return {
476
586
  cancelled: false,
477
587
  pairs,
@@ -486,17 +596,32 @@ async function showQuestionTUI(
486
596
  currentIndex: number,
487
597
  totalCount: number,
488
598
  titlePrefix = "设计方案评审",
599
+ backable = false,
600
+ previousAnswer?: string,
489
601
  ): Promise<string | null> {
490
602
  const selectItems: SelectItem[] = q.options.map((opt, i) => ({
491
603
  value: `opt-${i}`,
492
- label: `(${String.fromCharCode(97 + i)}) ${opt}`,
604
+ label: opt === previousAnswer
605
+ ? `(${String.fromCharCode(97 + i)}) ${opt} - 上次选择`
606
+ : `(${String.fromCharCode(97 + i)}) ${opt}`,
493
607
  }));
608
+
609
+ const customLabel = previousAnswer && !q.options.includes(previousAnswer)
610
+ ? `✏️ 自定义输入 - 上次选择`
611
+ : `✏️ 自定义输入`;
494
612
  selectItems.push({
495
613
  value: "__custom__",
496
- label: "✏️ 自定义输入",
614
+ label: customLabel,
497
615
  description: "输入你自己的回答,不受选项限制",
498
616
  });
499
617
 
618
+ if (backable && currentIndex > 1) {
619
+ selectItems.push({
620
+ value: "__back__",
621
+ label: "← 返回上一题",
622
+ });
623
+ }
624
+
500
625
  const title = `${titlePrefix} (问题 ${currentIndex}/${totalCount})`;
501
626
 
502
627
  const value = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
@@ -520,8 +645,11 @@ async function showQuestionTUI(
520
645
  container.addChild(selectList);
521
646
 
522
647
  container.addChild(new Spacer(1));
648
+ const hint = backable && currentIndex > 1
649
+ ? " ↑↓ 导航 • Enter 选择 • 选择←返回上一题 • Esc 取消全部评审"
650
+ : " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审";
523
651
  container.addChild(
524
- new Text(theme.fg("dim", " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审"), 0, 0),
652
+ new Text(theme.fg("dim", hint), 0, 0),
525
653
  );
526
654
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
527
655
 
@@ -535,13 +663,18 @@ async function showQuestionTUI(
535
663
  };
536
664
  });
537
665
 
666
+ if (value === "__back__") return "__BACK__";
538
667
  if (value === null) return null;
539
668
  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);
669
+ const custom = await uiInput(ctx, "✏️ 自定义回答",
670
+ previousAnswer && !q.options.includes(previousAnswer)
671
+ ? `(上次: ${previousAnswer.slice(0, 60)})`
672
+ : "输入你的回答内容(Esc 取消本题,回到选项)",
673
+ false, true,
674
+ previousAnswer && !q.options.includes(previousAnswer) ? previousAnswer : "",
675
+ );
676
+ if (custom === "__BACK__") return "__BACK__";
677
+ if (custom === undefined) return showQuestionTUI(ctx, q, currentIndex, totalCount, titlePrefix, backable, previousAnswer);
545
678
  return custom.trim() || "(空)";
546
679
  }
547
680
 
@@ -555,7 +688,7 @@ async function showQuestionTUI(
555
688
  * Run the PRD phase:
556
689
  * 1. Ask user if they want to create a PRD
557
690
  * 2. Call sub-agent → gets PRD Markdown
558
- * 3. Save to pi-dev-output/pi-prd/<name>.md
691
+ * 3. Save to .pi-dev-output/pi-prd/<name>.md
559
692
  * 4. Ask if user wants to start development
560
693
  */
561
694
  export async function runPRDPhase(
@@ -564,9 +697,10 @@ export async function runPRDPhase(
564
697
  pi: ExtensionAPI,
565
698
  ctx: ExtensionCommandContext,
566
699
  ): Promise<PRDResult | null> {
567
- const wantPrd = await ctx.ui.confirm(
700
+ const wantPrd = await uiConfirm(
701
+ ctx,
568
702
  "📋 创建 PRD",
569
- "是否为此功能创建 PRD 文档?\nPRD 将保存到 pi-dev-output/pi-prd/ 目录。",
703
+ "PRD 将保存到 .pi-dev-output/pi-prd/ 目录。",
570
704
  );
571
705
  if (!wantPrd) return null;
572
706
 
@@ -582,7 +716,7 @@ export async function runPRDPhase(
582
716
  context,
583
717
  ].join("\n");
584
718
 
585
- spawnSubagent(PRD_AGENT_DEF, prdTask, ctx.cwd, loader.signal)
719
+ spawnSubagent(_defaultPrdAgent, prdTask, ctx.cwd, loader.signal)
586
720
  .then((result) => {
587
721
  const output = extractFinalOutput(result.output);
588
722
  done(output && output.length >= 50 ? output : null);
@@ -593,7 +727,6 @@ export async function runPRDPhase(
593
727
  });
594
728
 
595
729
  if (!prdContent) {
596
- ctx.ui.notify("⚠️ PRD 生成失败", "error");
597
730
  return null;
598
731
  }
599
732
 
@@ -602,7 +735,6 @@ export async function runPRDPhase(
602
735
  const filePath = path.join(DEV_OUTPUT_DIR, PRD_DIRNAME, filename);
603
736
  const fullPath = path.join(prdDir, filename);
604
737
  fs.writeFileSync(fullPath, prdContent, "utf-8");
605
- ctx.ui.notify(`✅ PRD 已保存到 ${filePath}`, "success");
606
738
 
607
739
  await askDevelopmentStart(pi, ctx, prdContent, filePath);
608
740
  return { content: prdContent, filePath };
@@ -614,7 +746,8 @@ async function askDevelopmentStart(
614
746
  prdContent: string,
615
747
  prdFilePath: string,
616
748
  ): Promise<void> {
617
- const choice = await ctx.ui.select(
749
+ const choice = await uiSelect(
750
+ ctx,
618
751
  "🚀 是否开始开发?",
619
752
  [
620
753
  "是 — 根据 PRD 开始开发",
@@ -638,18 +771,13 @@ async function askDevelopmentStart(
638
771
  "",
639
772
  "请按照上述 PRD 逐步实现。先分析代码库结构,给出实施计划,确认后再编写代码。",
640
773
  ].join("\n");
641
- pi.sendUserMessage(devMsg);
642
- ctx.ui.notify("🚀 已发送开发指令给主代理", "success");
774
+ pi.sendUserMessage(devMsg, { deliverAs: "followUp" });
643
775
  break;
644
776
  }
645
777
  case "否 — 稍后手动开始":
646
- ctx.ui.notify(`📋 PRD 已保存在 ${prdFilePath},可随时手动引用`, "info");
647
778
  break;
648
779
  default: {
649
- const customMsg = await ctx.ui.input("✏️ 自定义开发指令", {
650
- placeholder: "输入你的开发指令(将结合 PRD 一起发送给主代理)",
651
- required: false,
652
- });
780
+ const customMsg = await uiInput(ctx, "✏️ 自定义开发指令", "输入你的开发指令(将结合 PRD 一起发送给主代理)");
653
781
  if (customMsg === undefined) return askDevelopmentStart(pi, ctx, prdContent, prdFilePath);
654
782
  const finalMsg = customMsg.trim()
655
783
  ? [
@@ -668,8 +796,7 @@ async function askDevelopmentStart(
668
796
  "--- PRD 全文 ---",
669
797
  prdContent,
670
798
  ].join("\n");
671
- pi.sendUserMessage(finalMsg);
672
- ctx.ui.notify("🚀 已发送自定义开发指令给主代理", "success");
799
+ pi.sendUserMessage(finalMsg, { deliverAs: "followUp" });
673
800
  break;
674
801
  }
675
802
  }