@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.
- package/.github/workflows/release.yml +56 -0
- package/.pre-commit-config.yaml +75 -0
- package/.version/RELEASE-v0.7.0.md +85 -0
- package/README.md +80 -228
- package/extensions/dev-prompts.ts +351 -450
- package/extensions/git-commands.ts +62 -171
- package/extensions/grill-me-agent.ts +98 -137
- package/extensions/session-utils.ts +169 -0
- package/extensions/ui-helpers.ts +2 -780
- package/package.json +2 -2
- package/prompts/APPEND_SYSTEM.md +63 -59
- package/skills/review-html/SKILL.md +1 -1
- package/tests/test-no-subagents.mjs +146 -0
- package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
- package/agents/git-agent.md +0 -44
- package/agents/grill/dev-doc-grill-agent.md +0 -42
- package/agents/grill/dev-fix-grill-agent.md +0 -44
- package/agents/grill/dev-grill-agent.md +0 -40
- package/agents/grill/dev-perf-grill-agent.md +0 -45
- package/agents/grill/dev-prd-agent.md +0 -60
- package/agents/grill/dev-refactor-grill-agent.md +0 -46
- package/agents/grill/dev-test-grill-agent.md +0 -45
- package/agents/review-agent.md +0 -53
- package/agents/workflow/docWriter-agent.md +0 -53
- package/agents/workflow/planner-agent.md +0 -131
- package/agents/workflow/reviewer-agent.md +0 -128
- package/agents/workflow/trimmer-agent.md +0 -78
- package/agents/workflow/worker-agent.md +0 -70
- package/extensions/sub-agents.ts +0 -954
- package/extensions/workflow-engine.ts +0 -2005
- package/tests/test-grill-json-fix.mjs +0 -243
- package/tests/test-loopcount-timeout-fix.mjs +0 -336
- package/tests/test-output-directory-structure.mjs +0 -177
- package/tests/test-save-answer-file-workflow.mjs +0 -187
- package/tests/test-workflow-config.mjs +0 -244
- package/tests/test-workflow-engine-bugs.mjs +0 -908
- 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() —
|
|
6
|
-
* 2. runPRDPhase() —
|
|
5
|
+
* 1. runGrillPhase() — 由当前代理生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
|
|
6
|
+
* 2. runPRDPhase() — 由当前代理生成 PRD,保存到 .pi-dev-output/pi-prd/
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
// ──
|
|
136
|
+
// ── File-based result extraction ─────────────────────────────
|
|
136
137
|
|
|
137
138
|
/**
|
|
138
|
-
* Helper: build the system prompt suffix that tells the
|
|
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
|
-
"###
|
|
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
|
|
184
|
-
"- Backslashes
|
|
185
|
-
"- Tabs
|
|
184
|
+
"- Newlines → \\n",
|
|
185
|
+
"- Backslashes → \\\\",
|
|
186
|
+
"- Tabs → \\t",
|
|
186
187
|
"",
|
|
187
|
-
"###
|
|
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\\
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|