@ghyper9023/pi-dev-workflow 0.6.2 → 0.7.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.
Files changed (37) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/.pre-commit-config.yaml +75 -0
  3. package/.version/RELEASE-v0.7.0.md +85 -0
  4. package/README.md +80 -228
  5. package/extensions/dev-prompts.ts +351 -450
  6. package/extensions/git-commands.ts +62 -171
  7. package/extensions/grill-me-agent.ts +98 -137
  8. package/extensions/session-utils.ts +169 -0
  9. package/extensions/ui-helpers.ts +2 -780
  10. package/package.json +2 -2
  11. package/prompts/APPEND_SYSTEM.md +63 -59
  12. package/skills/review-html/SKILL.md +1 -1
  13. package/tests/test-no-subagents.mjs +146 -0
  14. package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
  15. package/agents/git-agent.md +0 -44
  16. package/agents/grill/dev-doc-grill-agent.md +0 -42
  17. package/agents/grill/dev-fix-grill-agent.md +0 -44
  18. package/agents/grill/dev-grill-agent.md +0 -40
  19. package/agents/grill/dev-perf-grill-agent.md +0 -45
  20. package/agents/grill/dev-prd-agent.md +0 -60
  21. package/agents/grill/dev-refactor-grill-agent.md +0 -46
  22. package/agents/grill/dev-test-grill-agent.md +0 -45
  23. package/agents/review-agent.md +0 -53
  24. package/agents/workflow/docWriter-agent.md +0 -53
  25. package/agents/workflow/planner-agent.md +0 -131
  26. package/agents/workflow/reviewer-agent.md +0 -128
  27. package/agents/workflow/trimmer-agent.md +0 -78
  28. package/agents/workflow/worker-agent.md +0 -70
  29. package/extensions/sub-agents.ts +0 -954
  30. package/extensions/workflow-engine.ts +0 -2005
  31. package/tests/test-grill-json-fix.mjs +0 -243
  32. package/tests/test-loopcount-timeout-fix.mjs +0 -336
  33. package/tests/test-output-directory-structure.mjs +0 -177
  34. package/tests/test-save-answer-file-workflow.mjs +0 -187
  35. package/tests/test-workflow-config.mjs +0 -244
  36. package/tests/test-workflow-engine-bugs.mjs +0 -908
  37. package/tests/test-workflow-engine.mjs +0 -518
@@ -1,21 +1,22 @@
1
1
  /**
2
- * grill-me-agent.ts — 设计 (Grill) 和 PRD 生成的独立管理器
2
+ * grill-me-agent.ts — 设计 (Grill) 和 PRD 生成的管理器(运行在当前代理中)
3
3
  *
4
4
  * 职责:
5
- * 1. runGrillPhase() — 启动 sub-agent 生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
6
- * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 .pi-dev-output/pi-prd/
5
+ * 1. runGrillPhase() — 由当前代理生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
6
+ * 2. runPRDPhase() — 由当前代理生成 PRD,保存到 .pi-dev-output/pi-prd/
7
7
  *
8
- * 关键设计决策(修复 #2):
9
- * sub-agent 通过 `write` 工具将评审问题写入临时文件,主进程事后读取。
10
- * 不依赖从 NDJSON 响应文本中解析 JSON(多轮 tool-calling 场景不可靠)。
8
+ * 与旧版的区别:不再创建隔离的子代理进程,而是把追问/PRD 任务交给当前代理执行。
9
+ * 当前模型普遍具备 >=1M 上下文窗口,无需通过子代理隔离上下文。
10
+ *
11
+ * 关键设计:
12
+ * 当前代理通过 `write` 工具将结果写入临时文件,主进程事后读取。
13
+ * 不依赖从响应文本中解析 JSON(多轮 tool-calling 场景不可靠)。
11
14
  */
12
15
 
13
16
  import * as fs from "node:fs";
14
17
  import * as path from "node:path";
15
18
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
- import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
17
- import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef } from "./sub-agents";
18
- export type { AgentDef };
19
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
19
20
  import {
20
21
  Container,
21
22
  SelectList,
@@ -28,10 +29,11 @@ import {
28
29
  type SelectItem,
29
30
  } from "@earendil-works/pi-tui";
30
31
  import { uiSelect, uiConfirm, uiInput } from "./ui-helpers";
32
+ import { pollFor, getLastAssistantTextAfter } from "./session-utils";
31
33
 
32
34
  // ── Types ────────────────────────────────────────────────────
33
35
 
34
- /** A single grill question returned by the sub-agent. */
36
+ /** A single grill question returned by the agent. */
35
37
  export interface GrillQuestion {
36
38
  id: number;
37
39
  question: string;
@@ -104,7 +106,6 @@ export function saveAnswerFile(cwd: string, content: string): string {
104
106
  /**
105
107
  * Find the most recent answer backup file and read its content.
106
108
  * Returns undefined if no backup exists or read fails.
107
- * Now reads from pi-grill/answers/ subdirectory.
108
109
  */
109
110
  export function recoverFromBackup(cwd: string): string | undefined {
110
111
  const dir = path.join(cwd, DEV_OUTPUT_DIR, GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME);
@@ -132,10 +133,10 @@ function generatePrdFilename(moduleSuggestion: string): string {
132
133
  return `${safe || "feature"}-${ts}.md`;
133
134
  }
134
135
 
135
- // ── Sub-agent definitions ────────────────────────────────────
136
+ // ── File-based result extraction ─────────────────────────────
136
137
 
137
138
  /**
138
- * Helper: build the system prompt suffix that tells the sub-agent to
139
+ * Helper: build the system prompt suffix that tells the agent to
139
140
  * write results to a file via the `write` tool instead of putting JSON
140
141
  * in its chat response.
141
142
  */
@@ -176,15 +177,15 @@ function writeToolPromptSuffix(outputFilePath: string): string {
176
177
  '}',
177
178
  "```",
178
179
  "",
179
- "### \u26a0\ufe0f CRITICAL: String escaping rules",
180
+ "### ⚠️ CRITICAL: String escaping rules",
180
181
  "",
181
182
  "Every string value (question text, option text) MUST be valid JSON-escaped:",
182
183
  "- Double quotes inside text \u2192 \\\"",
183
- "- Newlines \u2192 \\n",
184
- "- Backslashes \u2192 \\\\",
185
- "- Tabs \u2192 \\t",
184
+ "- Newlines → \\n",
185
+ "- Backslashes → \\\\",
186
+ "- Tabs → \\t",
186
187
  "",
187
- "### \u2705 Self-review before writing",
188
+ "### ✅ Self-review before writing",
188
189
  "",
189
190
  "Before calling the `write` tool, mentally validate your JSON.",
190
191
  "Check that all strings are properly escaped and the structure matches the schema above.",
@@ -204,7 +205,7 @@ function writeToolPromptSuffix(outputFilePath: string): string {
204
205
  ' },',
205
206
  ' {',
206
207
  ' "id": 2,',
207
- ' "question": "\\u4ed6\\u8bf4\\u201c\\u8fd9\\u4e2a\\u4e0d\\u884c\\u201d\\uff0c\\u8be5\\u5982\\u4f55\\u5904\\u7406\\uff1f",',
208
+ ' "question": "\\u4ed6\\u8bf4\\u201c\\u8fd9\\u4e2a\\u4e88\\u884c\\u201d\\uff0c\\u8be5\\u5982\\u4f55\\u5904\\u7406\\uff1f",',
208
209
  ' "options": [',
209
210
  ' "\\u5ffd\\u7565",',
210
211
  ' "\\u4fee\\u590d"',
@@ -219,15 +220,8 @@ function writeToolPromptSuffix(outputFilePath: string): string {
219
220
  ].join("\n");
220
221
  }
221
222
 
222
- // ── Default agent definitions (loaded from agents/ directory) ────
223
-
224
- const _defaultGrillAgent = discoverAgents().find(a => a.name === "dev-grill-agent")!;
225
- const _defaultPrdAgent = discoverAgents().find(a => a.name === "dev-prd-agent")!;
226
-
227
- // ── File-based question extraction ───────────────────────────
228
-
229
223
  /**
230
- * Try to read and parse questions from the output file that the sub-agent
224
+ * Try to read and parse questions from the output file that the agent
231
225
  * was instructed to write via the `write` tool.
232
226
  */
233
227
  function readQuestionsFromFile(filePath: string): GrillQuestion[] {
@@ -258,7 +252,7 @@ function readQuestionsFromFile(filePath: string): GrillQuestion[] {
258
252
  }
259
253
 
260
254
  /**
261
- * Parse sub-agent output into a list of GrillQuestions.
255
+ * Parse agent output into a list of GrillQuestions.
262
256
  * Used as fallback when the file-based approach didn't produce results.
263
257
  */
264
258
  export function parseGrillQuestions(raw: string): GrillQuestion[] {
@@ -344,17 +338,15 @@ function extractQuestionArray(raw: string): Array<{ question: string; options: s
344
338
  * Options for customizing the grill phase.
345
339
  */
346
340
  export interface GrillOptions {
347
- agentDef?: AgentDef;
348
341
  title?: string;
349
342
  description?: string;
350
343
  questionTitle?: string;
351
- loaderLabel?: string;
352
344
  }
353
345
 
354
346
  /**
355
347
  * Run the grill phase:
356
348
  * 1. Confirm with user
357
- * 2. Call sub-agent → sub-agent writes questions to a file via `write` tool
349
+ * 2. Send task to current agent → agent writes questions to a file via `write` tool
358
350
  * 3. Read questions from the file
359
351
  * 4. Show each question as TUI SelectList + custom input option
360
352
  * 5. Collect all Q&A pairs
@@ -363,6 +355,7 @@ export interface GrillOptions {
363
355
  export async function runGrillPhase(
364
356
  assembledPrompt: string,
365
357
  ctx: ExtensionCommandContext,
358
+ pi: ExtensionAPI,
366
359
  options?: GrillOptions,
367
360
  ): Promise<GrillResult> {
368
361
  const defaultResult: GrillResult = {
@@ -371,11 +364,9 @@ export async function runGrillPhase(
371
364
  enhancedPrompt: assembledPrompt,
372
365
  };
373
366
 
374
- const agentDef = options?.agentDef ?? _defaultGrillAgent;
375
367
  const confirmTitle = options?.title ?? "🔍 设计方案追问完善";
376
368
  const confirmDesc = options?.description ?? "AI 会通过系统性追问帮你打磨方案:从术语精确化到边界条件验证,确保架构决策的每个分支都经过推敲。";
377
369
  const qTitlePrefix = options?.questionTitle ?? "设计方案追问完善";
378
- const loaderLabel = options?.loaderLabel ?? "🧠 AI 子代理正在分析代码并生成追问问题...";
379
370
 
380
371
  // ── Step 1: Confirm entering grill mode ──────────────────
381
372
  const enterGrill = await uiConfirm(ctx, confirmTitle, confirmDesc);
@@ -391,37 +382,17 @@ export async function runGrillPhase(
391
382
  writeToolPromptSuffix(outputFilePath),
392
383
  ].join("\n\n");
393
384
 
394
- // ── Step 3: Call sub-agent with BorderedLoader ───────────
395
- const questions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
396
- const loader = new BorderedLoader(tui, theme, loaderLabel);
397
- loader.onAbort = () => done([]);
398
-
399
- spawnSubagent(
400
- agentDef,
401
- enhancedPrompt,
402
- ctx.cwd,
403
- loader.signal,
404
- undefined,
405
- (progress) => {
406
- const inner = (loader as unknown as { loader?: { setText?: (t: string) => void } }).loader;
407
- inner?.setText?.(`🧠 ${progress.slice(0, 60)}`);
408
- },
409
- )
410
- .then((result) => {
411
- let qs = readQuestionsFromFile(outputFilePath);
412
- if (qs.length === 0) {
413
- const output = extractFinalOutput(result.output);
414
- qs = parseGrillQuestions(output);
415
- }
416
- done(qs);
417
- })
418
- .catch(() => {
419
- const qs = readQuestionsFromFile(outputFilePath);
420
- done(qs);
421
- });
422
-
423
- return loader;
424
- });
385
+ // ── Step 3: Ask current agent to generate questions ──────
386
+ ctx.ui.notify("🧠 已提交给当前代理生成追问问题,请稍候...", "info");
387
+ const sentAt = Date.now();
388
+ pi.sendUserMessage(enhancedPrompt, { deliverAs: "followUp" });
389
+
390
+ const questions = (await pollFor(() => {
391
+ const qs = readQuestionsFromFile(outputFilePath);
392
+ if (qs.length) return qs;
393
+ const parsed = parseGrillQuestions(getLastAssistantTextAfter(ctx, sentAt));
394
+ return parsed.length ? parsed : undefined;
395
+ }, 5 * 60_000)) || [];
425
396
 
426
397
  // ── Step 4: Retry dialog if no questions generated ──────
427
398
  if (questions.length === 0) {
@@ -449,43 +420,41 @@ export async function runGrillPhase(
449
420
  switch (choice) {
450
421
  case "🔄 重新尝试生成追问问题": {
451
422
  const retryPath = grillOutputPath(ctx.cwd);
452
- const errorFeedback = parseErrorMsg
453
- ? [
454
- "",
455
- "### ⚠️ Previous attempt had JSON errors — fix them now",
456
- "",
457
- `The previous attempt wrote to \`${outputFilePath}\` but the JSON was invalid.`,
458
- "",
459
- `JSON parse error: ${parseErrorMsg}`,
460
- "",
461
- "Invalid file content (first 2000 chars):",
462
- "```",
463
- failedFileContent.slice(0, 1000),
464
- "```",
465
- "",
466
- "Please write valid JSON to the new path below. Make sure all strings are properly JSON-escaped.",
467
- ].join("\n")
468
- : "";
469
423
  const retryPrompt = [
470
424
  assembledPrompt,
471
425
  writeToolPromptSuffix(retryPath),
472
- errorFeedback,
426
+ parseErrorMsg
427
+ ? [
428
+ "",
429
+ "### ⚠️ Previous attempt had JSON errors — fix them now",
430
+ "",
431
+ `The previous attempt wrote to \`${outputFilePath}\` but the JSON was invalid.`,
432
+ "",
433
+ `JSON parse error: ${parseErrorMsg}`,
434
+ "",
435
+ "Invalid file content (first 2000 chars):",
436
+ "```",
437
+ failedFileContent.slice(0, 1000),
438
+ "```",
439
+ "",
440
+ "Please write valid JSON to the new path below. Make sure all strings are properly JSON-escaped.",
441
+ ].join("\n")
442
+ : "",
473
443
  ].filter(Boolean).join("\n\n");
474
- const retryQuestions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
475
- const loader = new BorderedLoader(tui, theme, loaderLabel);
476
- loader.onAbort = () => done([]);
477
- spawnSubagent(agentDef, retryPrompt, ctx.cwd, loader.signal, undefined)
478
- .then((r) => {
479
- let qs = readQuestionsFromFile(retryPath);
480
- if (qs.length === 0) qs = parseGrillQuestions(extractFinalOutput(r.output));
481
- done(qs);
482
- })
483
- .catch(() => done([]));
484
- return loader;
485
- });
444
+
445
+ const retrySentAt = Date.now();
446
+ pi.sendUserMessage(retryPrompt, { deliverAs: "followUp" });
447
+
448
+ const retryQuestions = (await pollFor(() => {
449
+ const qs = readQuestionsFromFile(retryPath);
450
+ if (qs.length) return qs;
451
+ const parsed = parseGrillQuestions(getLastAssistantTextAfter(ctx, retrySentAt));
452
+ return parsed.length ? parsed : undefined;
453
+ }, 5 * 60_000)) || [];
486
454
  if (retryQuestions.length === 0) {
487
455
  return defaultResult;
488
456
  }
457
+
489
458
  // Replace questions with retry results (with back support)
490
459
  const pairs: Array<{ question: string; answer: string }> = [];
491
460
  let rIdx = 0;
@@ -504,7 +473,6 @@ export async function runGrillPhase(
504
473
  }
505
474
  return { ...defaultResult, cancelled: true, pairs };
506
475
  }
507
- // Overwrite if re-answering, otherwise append
508
476
  if (rIdx < pairs.length) {
509
477
  pairs[rIdx] = { question: q.question, answer };
510
478
  } else {
@@ -561,7 +529,6 @@ export async function runGrillPhase(
561
529
  return { ...defaultResult, cancelled: true, pairs };
562
530
  }
563
531
 
564
- // Overwrite if re-answering (back then forward), otherwise append
565
532
  if (qIdx < pairs.length) {
566
533
  pairs[qIdx] = { question: q.question, answer };
567
534
  } else {
@@ -600,15 +567,6 @@ export async function runGrillPhase(
600
567
  * - ↑↓ 选择, Enter 确认, Esc 取消全部评审
601
568
  * - Ctrl+Shift+← 返回上一题(仅当 backable=true 且 currentIndex > 1 时生效)
602
569
  * - 选择 "✏️ 自定义输入" 进入文本输入模式
603
- *
604
- * @param ctx - Extension command context for TUI rendering
605
- * @param q - The grill question to display
606
- * @param currentIndex - 1-based index of current question
607
- * @param totalCount - Total number of questions
608
- * @param titlePrefix - Prefix for the question title bar
609
- * @param backable - Whether navigating back to previous question is allowed
610
- * @param previousAnswer - Previous answer to pre-fill as "上次选择" marker
611
- * @returns Selected option text, "__BACK__" for back navigation, or null for cancel
612
570
  */
613
571
  async function showQuestionTUI(
614
572
  ctx: ExtensionCommandContext,
@@ -631,7 +589,6 @@ async function showQuestionTUI(
631
589
  return {
632
590
  value: `opt-${i}`,
633
591
  label: truncated,
634
- // 完整文本由下方的预览面板展示(支持换行),description 列无法换行故移除
635
592
  };
636
593
  });
637
594
 
@@ -683,7 +640,6 @@ async function showQuestionTUI(
683
640
  container.addChild(new Spacer(1));
684
641
  container.addChild(previewText);
685
642
 
686
- // 初始化预览为第一个选项
687
643
  if (q.options.length > 0) {
688
644
  const initialWrapped = wrapTextWithAnsi(q.options[0], previewWidth);
689
645
  previewText.setText(
@@ -718,7 +674,6 @@ async function showQuestionTUI(
718
674
  render: (w) => container.render(w),
719
675
  invalidate: () => container.invalidate(),
720
676
  handleInput: (data) => {
721
- // Ctrl+Shift+← → 返回上一题(SelectList 不处理该键,需自行拦截)
722
677
  if (backable && currentIndex > 1 && matchesKey(data, Key.ctrlShift("left"))) {
723
678
  done("__BACK__");
724
679
  return;
@@ -753,7 +708,7 @@ async function showQuestionTUI(
753
708
  /**
754
709
  * Run the PRD phase:
755
710
  * 1. Ask user if they want to create a PRD
756
- * 2. Call sub-agent → gets PRD Markdown
711
+ * 2. Call current agent → gets PRD Markdown
757
712
  * 3. Save to .pi-dev-output/pi-prd/<name>.md
758
713
  * 4. Ask if user wants to start development
759
714
  */
@@ -770,37 +725,44 @@ export async function runPRDPhase(
770
725
  );
771
726
  if (!wantPrd) return null;
772
727
 
773
- const prdContent = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
774
- const loader = new BorderedLoader(tui, theme, "📝 AI 正在生成 PRD 文档...");
775
- loader.onAbort = () => done(null);
776
-
777
- const prdTask = [
778
- "请根据以下上下文生成一份 PRD(产品需求文档)。",
779
- "输出格式必须是完整的 Markdown 文档。",
780
- "",
781
- "=== 上下文 ===",
782
- context,
783
- ].join("\n");
784
-
785
- spawnSubagent(_defaultPrdAgent, prdTask, ctx.cwd, loader.signal)
786
- .then((result) => {
787
- const output = extractFinalOutput(result.output);
788
- done(output && output.length >= 50 ? output : null);
789
- })
790
- .catch(() => done(null));
791
-
792
- return loader;
793
- });
794
-
795
- if (!prdContent) {
796
- return null;
797
- }
798
-
799
728
  const prdDir = ensureOutputDir(ctx.cwd, PRD_DIRNAME);
800
729
  const filename = generatePrdFilename(moduleHint);
801
730
  const filePath = path.join(DEV_OUTPUT_DIR, PRD_DIRNAME, filename);
802
731
  const fullPath = path.join(prdDir, filename);
803
- fs.writeFileSync(fullPath, prdContent, "utf-8");
732
+
733
+ const prdTask = [
734
+ "请根据以下上下文生成一份 PRD(产品需求文档)。",
735
+ "输出格式必须是完整的 Markdown 文档。",
736
+ "",
737
+ "=== 上下文 ===",
738
+ context,
739
+ "",
740
+ `请使用 write 工具将完整 PRD 写入以下路径: ${fullPath}`,
741
+ "不要在聊天中输出全文,只需确认写入完成。",
742
+ ].join("\n");
743
+
744
+ ctx.ui.notify("📝 已提交给当前代理生成 PRD,请稍候...", "info");
745
+ const sentAt = Date.now();
746
+ pi.sendUserMessage(prdTask, { deliverAs: "followUp" });
747
+
748
+ const prdContent = (await pollFor(() => {
749
+ let c = "";
750
+ try {
751
+ c = fs.readFileSync(fullPath, "utf-8").trim();
752
+ } catch { /* file not written yet */ }
753
+ if (c) return c;
754
+ return getLastAssistantTextAfter(ctx, sentAt) || undefined;
755
+ }, 5 * 60_000)) || "";
756
+
757
+ if (!prdContent || prdContent.length < 50) {
758
+ ctx.ui.notify("❌ PRD 生成失败:未获取到有效 PRD 内容,请查看当前代理的回复后重试。", "error");
759
+ return null;
760
+ }
761
+
762
+ // Fallback: if the agent chatted the PRD instead of writing it, persist it ourselves.
763
+ if (!fs.existsSync(fullPath)) {
764
+ fs.writeFileSync(fullPath, prdContent, "utf-8");
765
+ }
804
766
 
805
767
  await askDevelopmentStart(pi, ctx, prdContent, filePath);
806
768
  return { content: prdContent, filePath };
@@ -876,5 +838,4 @@ async function askDevelopmentStart(
876
838
  //
877
839
  export default function (_pi: ExtensionAPI) {
878
840
  // grill-me-agent is a helper module, not a standalone extension.
879
- // It is imported by dev-prompts.ts to provide grill + PRD phases.
880
841
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * session-utils.ts — 跨扩展共享的会话与等待工具
3
+ *
4
+ * 职责:
5
+ * 1. pollFor() — 轮询等待当前代理的产物(文件/文本)出现,带超时
6
+ * 2. getLastAssistantTextAfter() — 提取指定时间点之后最后一条 assistant 文本
7
+ *
8
+ * 供 git-commands.ts / grill-me-agent.ts / dev-prompts.ts 等扩展共享使用。
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ /** 项目基础信息,用于生成默认验收标准与默认字段。 */
16
+ export interface ProjectDefaults {
17
+ language: string;
18
+ testCmd: string;
19
+ lintCmd: string;
20
+ packageManager: string;
21
+ hasPreCommit: boolean;
22
+ hasCI: boolean;
23
+ }
24
+
25
+ function safeReaddir(dir: string): string[] {
26
+ try {
27
+ return fs.readdirSync(dir);
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "coverage", ".pi-dev-output"]);
34
+
35
+ /** 浅层递归收集文件扩展名(深度 2),用于语言推断。 */
36
+ function scanExtensions(cwd: string, depth: number): string[] {
37
+ const exts = new Set<string>();
38
+ const walk = (dir: string, d: number): void => {
39
+ if (d > depth) return;
40
+ for (const name of safeReaddir(dir)) {
41
+ const full = path.join(dir, name);
42
+ let stat: fs.Stats | undefined;
43
+ try {
44
+ stat = fs.statSync(full);
45
+ } catch {
46
+ continue;
47
+ }
48
+ if (stat.isDirectory()) {
49
+ if (!SKIP_DIRS.has(name)) walk(full, d + 1);
50
+ } else {
51
+ const m = name.match(/\.([a-z0-9]+)$/i);
52
+ if (m) exts.add(m[1]!.toLowerCase());
53
+ }
54
+ }
55
+ };
56
+ walk(cwd, depth);
57
+ return [...exts];
58
+ }
59
+
60
+ /** 探测当前项目的语言/测试/lint 命令与工程规范(pre-commit、CI)。 */
61
+ export function detectProjectDefaults(cwd: string): ProjectDefaults {
62
+ const d: ProjectDefaults = {
63
+ language: "",
64
+ testCmd: "",
65
+ lintCmd: "",
66
+ packageManager: "npm",
67
+ hasPreCommit: false,
68
+ hasCI: false,
69
+ };
70
+ try {
71
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
72
+ const scripts = pkg.scripts ?? {};
73
+ const rawTest = scripts.test ?? "";
74
+ d.testCmd = /no test specified/.test(rawTest) ? "" : rawTest;
75
+ d.lintCmd = scripts.lint ?? scripts["lint:fix"] ?? scripts.format ?? "";
76
+ const pm = pkg.packageManager ?? "";
77
+ d.packageManager = /pnpm/.test(pm) ? "pnpm" : /yarn/.test(pm) ? "yarn" : "npm";
78
+ const meta = [pkg.name ?? "", Object.keys(pkg.dependencies ?? {}), Object.keys(pkg.devDependencies ?? {})].flat().join(" ").toLowerCase();
79
+ if (fs.existsSync(path.join(cwd, "tsconfig.json")) || /typescript|tsx/.test(meta)) d.language = "TypeScript";
80
+ else if (/javascript|next|react|vue|node|nest/.test(meta)) d.language = "JavaScript";
81
+ } catch { /* 非 Node 项目 */ }
82
+ if (!d.language) {
83
+ const files = safeReaddir(cwd);
84
+ const exts = scanExtensions(cwd, 2);
85
+ if (fs.existsSync(path.join(cwd, "Cargo.toml"))) d.language = "Rust";
86
+ else if (fs.existsSync(path.join(cwd, "go.mod"))) d.language = "Go";
87
+ else if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "requirements.txt"))) d.language = "Python";
88
+ else if (fs.existsSync(path.join(cwd, "pom.xml")) || fs.existsSync(path.join(cwd, "build.gradle"))) d.language = "Java";
89
+ else if (files.some((f) => f.endsWith(".csproj"))) d.language = "C#";
90
+ else if (exts.includes("rs")) d.language = "Rust";
91
+ else if (exts.includes("go")) d.language = "Go";
92
+ else if (exts.includes("py")) d.language = "Python";
93
+ else if (exts.includes("ts") || exts.includes("tsx")) d.language = "TypeScript";
94
+ else if (exts.some((e) => ["js", "mjs", "cjs", "jsx"].includes(e))) d.language = "JavaScript";
95
+ }
96
+ d.hasPreCommit = fs.existsSync(path.join(cwd, ".pre-commit-config.yaml")) || fs.existsSync(path.join(cwd, ".pre-commit-config.yml"));
97
+ d.hasCI = safeReaddir(path.join(cwd, ".github", "workflows")).some((f) => /\.ya?ml$/.test(f));
98
+ return d;
99
+ }
100
+
101
+ /** 根据项目探测结果生成常规验收标准(测试 + lint + pre-commit + CI)。 */
102
+ export function defaultAcceptance(d: ProjectDefaults): string {
103
+ const parts: string[] = [];
104
+ if (d.testCmd) parts.push(`运行 ${d.testCmd} 确认全部测试通过、无回归`);
105
+ else parts.push(`按 ${d.language || "项目"} 的常规测试方式编写并运行测试,确认核心逻辑正确、无回归`);
106
+ if (d.lintCmd) parts.push(`运行 ${d.lintCmd} 符合代码规范`);
107
+ if (d.hasPreCommit) parts.push("通过本地 pre-commit 钩子检查");
108
+ if (d.hasCI) parts.push("通过 CI 检查");
109
+ if (parts.length === 0) parts.push("核心逻辑正确、边界情况处理完善、无行为回归");
110
+ return parts.join(";");
111
+ }
112
+
113
+ /**
114
+ * 轮询直到回调返回非空/非 undefined,或超时。
115
+ * 用于替代 "sendUserMessage 后再 waitForIdle" 的不可靠时序:
116
+ * waitForIdle 可能在消息触发的新 turn 开始前立即返回,导致误判完成。
117
+ */
118
+ export async function pollFor<T>(poll: () => T | undefined | null, timeoutMs: number, intervalMs = 2_000): Promise<T | undefined> {
119
+ const deadline = Date.now() + timeoutMs;
120
+ while (Date.now() < deadline) {
121
+ const value = poll();
122
+ if (value !== undefined && value !== null && value !== "") return value;
123
+ await new Promise((r) => setTimeout(r, intervalMs));
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * Extract the most recent assistant message text that arrived after a given moment.
130
+ * Used as fallback when the agent did not write the expected file.
131
+ */
132
+ export function getLastAssistantTextAfter(ctx: ExtensionCommandContext, afterMs: number): string {
133
+ try {
134
+ const entries = ctx.sessionManager.getEntries();
135
+ let text = "";
136
+ for (const entry of entries) {
137
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
138
+ const ts = new Date(entry.timestamp).getTime();
139
+ if (ts > afterMs) {
140
+ text = extractMessageText(entry.message.content) || text;
141
+ }
142
+ }
143
+ return text;
144
+ } catch {
145
+ return "";
146
+ }
147
+ }
148
+
149
+ function extractMessageText(content: unknown): string {
150
+ if (typeof content === "string") return content;
151
+ if (Array.isArray(content)) {
152
+ return content
153
+ .map((part) => {
154
+ if (typeof part === "string") return part;
155
+ if (part && typeof part === "object" && "type" in part && part.type === "text") {
156
+ return (part as { text: string }).text;
157
+ }
158
+ return "";
159
+ })
160
+ .join("\n");
161
+ }
162
+ return "";
163
+ }
164
+
165
+ // ── Extension factory (required by pi extension loader) ─────
166
+
167
+ export default function (_pi: ExtensionAPI) {
168
+ // session-utils is a helper module, imported by other extensions.
169
+ }