@unifan/pi-unifan-zh 1.0.36 → 1.0.38
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/extensions/workflow/index.ts +160 -38
- package/extensions/workflow/package.json +1 -1
- package/extensions/workflow/skills/00-next/SKILL.md +2 -5
- package/extensions/workflow/src/driver/trigger-matcher.ts +37 -0
- package/extensions/workflow/src/driver/work-loop-driver.ts +19 -1
- package/extensions/workflow/src/tools/workflow-dashboard.ts +325 -0
- package/extensions/workflow/tests/workflow.test.mjs +273 -2
- package/package.json +1 -1
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ArtifactType } from "./src/tools/artifact-helper.js";
|
|
3
3
|
import type { WorkLoopDriver } from "./src/driver/work-loop-driver.js";
|
|
4
|
-
import {
|
|
5
|
-
import { filterReadOutput } from "./src/filters/read-output-filter.js";
|
|
4
|
+
import { isExplicit03WorkTrigger } from "./src/driver/trigger-matcher.js";
|
|
6
5
|
|
|
7
6
|
let workDriverInstance: WorkLoopDriver | null = null;
|
|
8
7
|
async function getWorkDriver(): Promise<WorkLoopDriver> {
|
|
@@ -180,6 +179,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
180
179
|
if (textBlocks.length === 0) return undefined;
|
|
181
180
|
|
|
182
181
|
const output = textBlocks.map((b) => b.text ?? "").join("");
|
|
182
|
+
const { filterBashOutput } = await import("./src/filters/bash-output-filter.js");
|
|
183
183
|
const result = filterBashOutput({
|
|
184
184
|
command,
|
|
185
185
|
output,
|
|
@@ -218,6 +218,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
218
218
|
const isImage =
|
|
219
219
|
(event.content as Array<{ type: string }>)?.some((b) => b.type === "image") ?? false;
|
|
220
220
|
|
|
221
|
+
const { filterReadOutput } = await import("./src/filters/read-output-filter.js");
|
|
221
222
|
const result = filterReadOutput({
|
|
222
223
|
path: filePath,
|
|
223
224
|
output,
|
|
@@ -240,59 +241,143 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
240
241
|
};
|
|
241
242
|
});
|
|
242
243
|
|
|
243
|
-
// 6. User Command: /workflow (
|
|
244
|
+
// 6. User Command: /workflow (查看工作流全阶段看板与自主循环控制)
|
|
244
245
|
pi.registerCommand("workflow", {
|
|
245
|
-
description: "
|
|
246
|
-
async handler(
|
|
246
|
+
description: "查看复合工程工作流全流程步骤状态看板与自主干活循环控制",
|
|
247
|
+
async handler(args, ctx) {
|
|
248
|
+
const sub = (args || "").trim().toLowerCase();
|
|
249
|
+
const workDriver = await getWorkDriver();
|
|
250
|
+
|
|
251
|
+
if (sub === "pause" || sub === "暂停") {
|
|
252
|
+
if (workDriver.getStatus().isActive) {
|
|
253
|
+
detachEscapeListener();
|
|
254
|
+
await workDriver.pause("用户执行 /workflow pause");
|
|
255
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
256
|
+
ctx.ui?.notify?.("⏸️ 03-work 自主循环已安全暂停。", "info");
|
|
257
|
+
} else {
|
|
258
|
+
ctx.ui?.notify?.("⚪ 03-work 当前未处于活跃运行状态。", "info");
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (sub === "resume" || sub === "继续") {
|
|
264
|
+
workDriver.setRepoRoot(ctx.cwd || process.cwd());
|
|
265
|
+
const res = await workDriver.start();
|
|
266
|
+
if (res.success && res.status.isActive) {
|
|
267
|
+
attachEscapeListener(ctx);
|
|
268
|
+
ctx.ui?.setStatus?.(
|
|
269
|
+
"workflow",
|
|
270
|
+
`🔄 03-work 自主干活 [${res.status.completedUnits.length}/${res.status.allUnits.length}]`,
|
|
271
|
+
);
|
|
272
|
+
ctx.ui?.notify?.("🚀 03-work 自主循环已恢复驱动。", "info");
|
|
273
|
+
} else {
|
|
274
|
+
ctx.ui?.notify?.(res.message, "info");
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (sub === "hide" || sub === "off" || sub === "close" || sub === "关闭" || sub === "隐藏") {
|
|
280
|
+
ctx.ui?.setWidget?.("workflow", undefined);
|
|
281
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
282
|
+
ctx.ui?.notify?.("工作流状态栏已隐藏。", "info");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
247
286
|
const { detectWorkflowState } = await import("./src/tools/workflow-state.js");
|
|
287
|
+
const { buildWorkflowDashboard, buildWorkflowWidgetLines } = await import(
|
|
288
|
+
"./src/tools/workflow-dashboard.js"
|
|
289
|
+
);
|
|
248
290
|
const repoRoot = ctx.cwd || process.cwd();
|
|
249
291
|
const state = await detectWorkflowState(repoRoot);
|
|
250
|
-
const workDriver = await getWorkDriver();
|
|
251
292
|
const workStatus = workDriver.getStatus();
|
|
252
293
|
|
|
253
|
-
const
|
|
254
|
-
`🎯 **复合工程工作流状态 (Compound Engineering)**`,
|
|
255
|
-
`📁 仓库路径: \`${state.repoRoot}\``,
|
|
256
|
-
`📊 当前阶段: **${state.stage.toUpperCase()}**`,
|
|
257
|
-
`🚀 推荐下一步: \`/skill:${state.recommendedSkill}\``,
|
|
258
|
-
`💡 理由: ${state.recommendationReason}`,
|
|
259
|
-
``,
|
|
260
|
-
workStatus.isActive
|
|
261
|
-
? `⚡ **03-work 自主循环运行中**: [${workStatus.completedUnits.length}/${workStatus.allUnits.length}] 当前推进: **${workStatus.currentUnit || "全部完成"}**`
|
|
262
|
-
: `⚡ **03-work 自主循环状态**: ⚪ 空闲(调用 /skill:03-work 即可自动循环驱动)`,
|
|
263
|
-
``,
|
|
264
|
-
`📋 **产物概览**:`,
|
|
265
|
-
`- 需求文档 (Brainstorms): ${state.brainstorms.length} 个 ${state.latestBrainstorm ? `(最新: ${state.latestBrainstorm.filename})` : ""}`,
|
|
266
|
-
`- 执行计划 (Plans): ${state.plans.length} 个 ${state.latestPlan ? `(最新: ${state.latestPlan.filename})` : ""}`,
|
|
267
|
-
`- 运行断点 (Checkpoints): ${state.checkpoints.length} 个 ${state.activeCheckpoint ? `(已完成: ${state.activeCheckpoint.completedUnits.length} 单元)` : ""}`,
|
|
268
|
-
`- 避坑经验 (Solutions): ${state.solutions.length} 个 ${state.latestSolution ? `(最新: ${state.latestSolution.filename})` : ""}`,
|
|
269
|
-
].join("\n");
|
|
270
|
-
|
|
294
|
+
const dashboard = buildWorkflowDashboard(state, workStatus);
|
|
271
295
|
if (ctx.hasUI) {
|
|
272
|
-
ctx.ui.notify?.(
|
|
296
|
+
ctx.ui.notify?.(dashboard, "info");
|
|
297
|
+
const widgetLines = buildWorkflowWidgetLines(state, workStatus);
|
|
298
|
+
ctx.ui.setWidget?.("workflow", widgetLines, { placement: "aboveEditor" });
|
|
273
299
|
}
|
|
274
300
|
},
|
|
275
301
|
});
|
|
276
302
|
|
|
277
|
-
|
|
278
|
-
|
|
303
|
+
pi.registerCommand("workflow-pause", {
|
|
304
|
+
description: "暂停当前正在运行的 03-work 自主干活循环 (同 Esc / 输入 '暂停')",
|
|
305
|
+
async handler(_args, ctx) {
|
|
306
|
+
const workDriver = await getWorkDriver();
|
|
307
|
+
if (workDriver.getStatus().isActive) {
|
|
308
|
+
detachEscapeListener();
|
|
309
|
+
await workDriver.pause("用户执行 /workflow-pause");
|
|
310
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
311
|
+
ctx.ui?.notify?.("⏸️ 03-work 自主循环已安全暂停。", "info");
|
|
312
|
+
} else {
|
|
313
|
+
ctx.ui?.notify?.("⚪ 03-work 当前未处于活跃运行状态。", "info");
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pi.registerCommand("workflow-resume", {
|
|
319
|
+
description: "恢复当前计划的 03-work 自主干活循环 (同 /skill:03-work / 输入 '继续干活')",
|
|
320
|
+
async handler(_args, ctx) {
|
|
321
|
+
const workDriver = await getWorkDriver();
|
|
322
|
+
workDriver.setRepoRoot(ctx.cwd || process.cwd());
|
|
323
|
+
const res = await workDriver.start();
|
|
324
|
+
if (res.success && res.status.isActive) {
|
|
325
|
+
attachEscapeListener(ctx);
|
|
326
|
+
ctx.ui?.setStatus?.(
|
|
327
|
+
"workflow",
|
|
328
|
+
`🔄 03-work 自主干活 [${res.status.completedUnits.length}/${res.status.allUnits.length}]`,
|
|
329
|
+
);
|
|
330
|
+
ctx.ui?.notify?.("🚀 03-work 自主循环已恢复驱动。", "info");
|
|
331
|
+
} else {
|
|
332
|
+
ctx.ui?.notify?.(res.message, "info");
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 7. 03-work 原生自主循环驱动引擎与极轻量按需键盘中断监听
|
|
338
|
+
// 仅在 03-work 自主循环运行时按需挂载 Esc 监听,暂停或结束时立即脱钩,日常打字 0 开销
|
|
339
|
+
let terminalInputUnsub: (() => void) | null = null;
|
|
340
|
+
async function attachEscapeListener(ctx: ExtensionContext) {
|
|
341
|
+
if (ctx.hasUI && !terminalInputUnsub && ctx.ui?.onTerminalInput) {
|
|
342
|
+
const { matchesKey } = await import("@earendil-works/pi-tui");
|
|
343
|
+
terminalInputUnsub = ctx.ui.onTerminalInput((data: string) => {
|
|
344
|
+
if (
|
|
345
|
+
(data === "\x1b" || matchesKey(data, "escape")) &&
|
|
346
|
+
workDriverInstance &&
|
|
347
|
+
workDriverInstance.getStatus().isActive
|
|
348
|
+
) {
|
|
349
|
+
detachEscapeListener();
|
|
350
|
+
workDriverInstance.pause("用户按 Esc 中断");
|
|
351
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
352
|
+
ctx.ui?.notify?.(
|
|
353
|
+
"⏸️ 03-work 自主循环已响应 Esc 安全暂停。随时输入“继续”或调用 /skill:03-work 即可恢复。",
|
|
354
|
+
"info",
|
|
355
|
+
);
|
|
356
|
+
// 不消费按键,透传给宿主让 Pi 终止当前正在运行的模型回合或工具命令
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return undefined;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function detachEscapeListener() {
|
|
365
|
+
if (terminalInputUnsub) {
|
|
366
|
+
terminalInputUnsub();
|
|
367
|
+
terminalInputUnsub = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 监听用户输入与技能启动:只有显式触发 03-work 时才启动自主循环(绝不读取 systemPrompt)
|
|
279
372
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
280
|
-
const
|
|
281
|
-
const is03Work =
|
|
282
|
-
event.prompt.includes("03-work") ||
|
|
283
|
-
event.systemPrompt.includes("03-work") ||
|
|
284
|
-
(promptLower.includes("03") &&
|
|
285
|
-
(promptLower.includes("work") ||
|
|
286
|
-
promptLower.includes("继续") ||
|
|
287
|
-
promptLower.includes("恢复") ||
|
|
288
|
-
promptLower.includes("resume") ||
|
|
289
|
-
promptLower.includes("干活")));
|
|
373
|
+
const is03Work = isExplicit03WorkTrigger(event.prompt);
|
|
290
374
|
|
|
291
375
|
if (is03Work && (!workDriverInstance || !workDriverInstance.getStatus().isActive)) {
|
|
292
376
|
const workDriver = await getWorkDriver();
|
|
293
377
|
workDriver.setRepoRoot(ctx.cwd || process.cwd());
|
|
294
378
|
const res = await workDriver.start();
|
|
295
379
|
if (res.success && res.status.isActive) {
|
|
380
|
+
attachEscapeListener(ctx);
|
|
296
381
|
const nextUnit = res.status.currentUnit ? `下一个: ${res.status.currentUnit}` : "准备就绪";
|
|
297
382
|
ctx.ui?.setStatus?.(
|
|
298
383
|
"workflow",
|
|
@@ -304,12 +389,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
304
389
|
|
|
305
390
|
// 监听用户打断指令:用户若输入“暂停”或“停止”,自动安全挂起 03 自主循环
|
|
306
391
|
pi.on("input", async (event, ctx) => {
|
|
392
|
+
if (event.source === "extension") return { action: "continue" };
|
|
393
|
+
|
|
307
394
|
const text = event.text.trim().toLowerCase();
|
|
308
395
|
if (
|
|
309
396
|
workDriverInstance &&
|
|
310
397
|
workDriverInstance.getStatus().isActive &&
|
|
311
398
|
(text === "暂停" || text === "停止" || text === "pause" || text === "stop")
|
|
312
399
|
) {
|
|
400
|
+
detachEscapeListener();
|
|
313
401
|
await workDriverInstance.pause("用户手动输入暂停");
|
|
314
402
|
ctx.ui?.setStatus?.("workflow", undefined);
|
|
315
403
|
ctx.ui?.notify?.(
|
|
@@ -318,17 +406,51 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
318
406
|
);
|
|
319
407
|
return { action: "handled" };
|
|
320
408
|
}
|
|
409
|
+
|
|
321
410
|
return { action: "continue" };
|
|
322
411
|
});
|
|
323
412
|
|
|
413
|
+
// 监听单轮结束 (turn_end):若因 Ctrl+C / Esc 导致当前轮次被中止,立即安全挂起
|
|
414
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
415
|
+
const msg = event.message as any;
|
|
416
|
+
if (
|
|
417
|
+
workDriverInstance &&
|
|
418
|
+
workDriverInstance.getStatus().isActive &&
|
|
419
|
+
msg?.role === "assistant" &&
|
|
420
|
+
msg?.stopReason === "aborted"
|
|
421
|
+
) {
|
|
422
|
+
detachEscapeListener();
|
|
423
|
+
await workDriverInstance.pause("检测到助理回合中断 (stopReason: aborted)");
|
|
424
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// 监听整段运行结束 (agent_end):检测 abort 信号或中止消息并挂起,防止 runaway 循环
|
|
429
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
430
|
+
if (workDriverInstance && workDriverInstance.getStatus().isActive) {
|
|
431
|
+
const hasAborted =
|
|
432
|
+
ctx.signal?.aborted ||
|
|
433
|
+
event.messages.some((m: any) => m?.role === "assistant" && m?.stopReason === "aborted");
|
|
434
|
+
if (hasAborted) {
|
|
435
|
+
detachEscapeListener();
|
|
436
|
+
await workDriverInstance.pause("检测到会话中断信号 (Ctrl+C / Esc)");
|
|
437
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
324
442
|
// 核心事件循环:每个回合结束后,若仍有未完成单元,自动注入下一回合实现“不做完不停机”
|
|
325
443
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
326
444
|
if (workDriverInstance && workDriverInstance.getStatus().isActive) {
|
|
327
445
|
await workDriverInstance.onAgentSettled(ctx, pi);
|
|
446
|
+
if (!workDriverInstance.getStatus().isActive) {
|
|
447
|
+
detachEscapeListener();
|
|
448
|
+
}
|
|
328
449
|
}
|
|
329
450
|
});
|
|
330
451
|
|
|
331
452
|
pi.on("session_shutdown", async () => {
|
|
453
|
+
detachEscapeListener();
|
|
332
454
|
workDriverInstance?.cancelTimer();
|
|
333
455
|
});
|
|
334
456
|
}
|
|
@@ -34,8 +34,5 @@ description: "检查工作流产物并推荐/导航到最佳的下一步技能
|
|
|
34
34
|
## 执行流程
|
|
35
35
|
|
|
36
36
|
1. 调用 `workflow_state({ repoRoot: "." })`。
|
|
37
|
-
2.
|
|
38
|
-
|
|
39
|
-
- 🚀 推荐技能(如 `/skill:01-brainstorm`、`/skill:02-plan` 等)
|
|
40
|
-
- 💡 推荐理由
|
|
41
|
-
3. 询问用户是否立刻进入推荐技能。
|
|
37
|
+
2. 输出状态概览与推荐技能(亦可随时运行 `/workflow` 查看全量看板)。
|
|
38
|
+
3. 询问用户是否进入推荐技能。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, lightweight trigger matcher for 03-work.
|
|
3
|
+
* Has ZERO external imports to ensure near-zero cold startup overhead.
|
|
4
|
+
*/
|
|
5
|
+
export function isExplicit03WorkTrigger(prompt: string): boolean {
|
|
6
|
+
if (!prompt) return false;
|
|
7
|
+
const trimmed = prompt.trim();
|
|
8
|
+
const lower = trimmed.toLowerCase();
|
|
9
|
+
|
|
10
|
+
// 1. Direct skill invocation or slash command
|
|
11
|
+
if (
|
|
12
|
+
lower.startsWith("/skill:03-work") ||
|
|
13
|
+
lower === "/skill:03"
|
|
14
|
+
) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Strict keywords and natural language commands to start/resume 03 work
|
|
19
|
+
const strictPatterns = [
|
|
20
|
+
/^开始(03|干活|编码|实现|写代码)/,
|
|
21
|
+
/^执行(03|03-work|干活)/,
|
|
22
|
+
/^自主(干活|工作|编码)/,
|
|
23
|
+
/^(继续|恢复)(干活|03|工作|work)/i,
|
|
24
|
+
/^resume\s+(03|work)/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
if (strictPatterns.some((p) => p.test(trimmed))) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 3. Autonomous continuation follow-up prompt generated by the driver itself
|
|
32
|
+
if (trimmed.startsWith("【03-work 自主循环驱动引擎")) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -95,6 +95,8 @@ export async function findLatestPlanFile(repoRoot: string): Promise<string | nul
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export { isExplicit03WorkTrigger } from "./trigger-matcher.js";
|
|
99
|
+
|
|
98
100
|
/**
|
|
99
101
|
* Autonomous Loop Driver for 03-work.
|
|
100
102
|
* Ensures the agent drives continuously across turns until all planned units are 100% finished.
|
|
@@ -282,13 +284,29 @@ export class WorkLoopDriver {
|
|
|
282
284
|
async onAgentSettled(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
|
283
285
|
if (!this.isActive || !this.planPath) return;
|
|
284
286
|
|
|
285
|
-
// 1. Check if user aborted (Esc pressed or
|
|
287
|
+
// 1. Check if user aborted (Esc pressed, abort signal, or aborted assistant message)
|
|
286
288
|
if (ctx.signal?.aborted) {
|
|
287
289
|
await this.pause("检测到用户中断信号 (Esc/Abort)");
|
|
290
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
288
291
|
ctx.ui?.notify?.("⏸️ 03-work 自主循环已响应中断信号安全暂停。", "info");
|
|
289
292
|
return;
|
|
290
293
|
}
|
|
291
294
|
|
|
295
|
+
try {
|
|
296
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
297
|
+
const lastMsgEntry = branch
|
|
298
|
+
.slice()
|
|
299
|
+
.reverse()
|
|
300
|
+
.find((e: any) => e?.type === "message" && e?.message?.role === "assistant");
|
|
301
|
+
const lastAssistantMsg = (lastMsgEntry as any)?.message;
|
|
302
|
+
if (lastAssistantMsg?.stopReason === "aborted") {
|
|
303
|
+
await this.pause("检测到会话中断 (stopReason: aborted)");
|
|
304
|
+
ctx.ui?.setStatus?.("workflow", undefined);
|
|
305
|
+
ctx.ui?.notify?.("⏸️ 03-work 自主循环已响应中断信号安全暂停。", "info");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
|
|
292
310
|
// 2. Re-read checkpoint from disk to see what the agent achieved in this turn
|
|
293
311
|
const cpResult = await executeSessionCheckpoint({
|
|
294
312
|
operation: "load",
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type { WorkflowStateResult } from "./workflow-state.js";
|
|
2
|
+
import type { WorkStatus } from "../driver/work-loop-driver.js";
|
|
3
|
+
|
|
4
|
+
export interface WorkflowStepItem {
|
|
5
|
+
number: string;
|
|
6
|
+
id: "01-brainstorm" | "02-plan" | "03-work" | "04-review" | "05-learn";
|
|
7
|
+
title: string;
|
|
8
|
+
status: "completed" | "in_progress" | "ready" | "pending" | "idle";
|
|
9
|
+
statusLabel: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
isCurrent: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generates an ASCII progress bar, e.g. [██████░░░░░░░░░░] 38%
|
|
16
|
+
*/
|
|
17
|
+
export function renderProgressBar(completed: number, total: number, barLength = 12): string {
|
|
18
|
+
if (total <= 0) return "░".repeat(barLength);
|
|
19
|
+
const ratio = Math.min(1, Math.max(0, completed / total));
|
|
20
|
+
const filled = Math.round(ratio * barLength);
|
|
21
|
+
const empty = barLength - filled;
|
|
22
|
+
return "█".repeat(filled) + "░".repeat(empty);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Derives the full 5-step status array from workflow state and driver status.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveWorkflowSteps(
|
|
29
|
+
state: WorkflowStateResult,
|
|
30
|
+
workStatus: WorkStatus,
|
|
31
|
+
): WorkflowStepItem[] {
|
|
32
|
+
// Determine current active step
|
|
33
|
+
let currentStepId: WorkflowStepItem["id"] = state.recommendedSkill || "01-brainstorm";
|
|
34
|
+
if (workStatus.isActive) {
|
|
35
|
+
currentStepId = "03-work";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const steps: WorkflowStepItem[] = [];
|
|
39
|
+
|
|
40
|
+
// Step 01: 01-brainstorm
|
|
41
|
+
const hasBrainstorm = state.brainstorms.length > 0;
|
|
42
|
+
steps.push({
|
|
43
|
+
number: "01",
|
|
44
|
+
id: "01-brainstorm",
|
|
45
|
+
title: "需求发现与规格说明",
|
|
46
|
+
status: hasBrainstorm ? "completed" : "ready",
|
|
47
|
+
statusLabel: hasBrainstorm ? "[已完成]" : "[就绪]",
|
|
48
|
+
detail: hasBrainstorm
|
|
49
|
+
? `${state.brainstorms.length} 个需求文档 (最新: ${state.latestBrainstorm?.filename || "-"})`
|
|
50
|
+
: "建议梳理目标、边界与成功标准",
|
|
51
|
+
isCurrent: currentStepId === "01-brainstorm",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Step 02: 02-plan
|
|
55
|
+
const hasPlan = state.plans.length > 0;
|
|
56
|
+
steps.push({
|
|
57
|
+
number: "02",
|
|
58
|
+
id: "02-plan",
|
|
59
|
+
title: "架构设计与TDD计划",
|
|
60
|
+
status: hasPlan ? "completed" : hasBrainstorm ? "ready" : "pending",
|
|
61
|
+
statusLabel: hasPlan ? "[已完成]" : hasBrainstorm ? "[就绪]" : "[待办]",
|
|
62
|
+
detail: hasPlan
|
|
63
|
+
? `${state.plans.length} 个计划文档 (最新: ${state.latestPlan?.filename || "-"})`
|
|
64
|
+
: hasBrainstorm
|
|
65
|
+
? "可基于需求拆解架构与 TDD 实施单元"
|
|
66
|
+
: "待梳理需求文档",
|
|
67
|
+
isCurrent: currentStepId === "02-plan",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Step 03: 03-work
|
|
71
|
+
const totalUnits = state.totalUnitsCount ?? workStatus.allUnits.length;
|
|
72
|
+
const completedUnits = state.completedUnitsCount ?? workStatus.completedUnits.length;
|
|
73
|
+
const remainingUnits = state.remainingUnitsCount ?? workStatus.remainingUnits.length;
|
|
74
|
+
|
|
75
|
+
let workStepStatus: WorkflowStepItem["status"] = "idle";
|
|
76
|
+
let workStepLabel = "[空闲]";
|
|
77
|
+
let workStepDetail = "待制定带实施单元的计划文档";
|
|
78
|
+
|
|
79
|
+
if (workStatus.isActive) {
|
|
80
|
+
workStepStatus = "in_progress";
|
|
81
|
+
workStepLabel = "[⚡ 运行中]";
|
|
82
|
+
const percent = totalUnits > 0 ? Math.round((completedUnits / totalUnits) * 100) : 0;
|
|
83
|
+
workStepDetail = `单元进度: [${renderProgressBar(completedUnits, totalUnits, 8)}] ${completedUnits}/${totalUnits} (${percent}%) 当前: ${workStatus.currentUnit || "就绪"}`;
|
|
84
|
+
} else if (workStatus.lastMessage && workStatus.lastMessage.includes("暂停")) {
|
|
85
|
+
workStepStatus = "in_progress";
|
|
86
|
+
workStepLabel = "[⏸️ 已暂停]";
|
|
87
|
+
const percent = totalUnits > 0 ? Math.round((completedUnits / totalUnits) * 100) : 0;
|
|
88
|
+
workStepDetail = `单元进度: [${renderProgressBar(completedUnits, totalUnits, 8)}] ${completedUnits}/${totalUnits} (${percent}%),已安全暂存`;
|
|
89
|
+
} else if (state.activeCheckpoint?.failedUnit) {
|
|
90
|
+
workStepStatus = "in_progress";
|
|
91
|
+
workStepLabel = "[🛑 遇错暂停]";
|
|
92
|
+
workStepDetail = `失败单元: ${state.activeCheckpoint.failedUnit},建议排查报错`;
|
|
93
|
+
} else if (totalUnits > 0) {
|
|
94
|
+
if (remainingUnits === 0) {
|
|
95
|
+
workStepStatus = "completed";
|
|
96
|
+
workStepLabel = "[已完成]";
|
|
97
|
+
workStepDetail = `全部 ${totalUnits} 个规划单元测试均已绿灯通过`;
|
|
98
|
+
} else if (completedUnits > 0) {
|
|
99
|
+
workStepStatus = "in_progress";
|
|
100
|
+
workStepLabel = "[进行中]";
|
|
101
|
+
const percent = Math.round((completedUnits / totalUnits) * 100);
|
|
102
|
+
workStepDetail = `单元进度: [${renderProgressBar(completedUnits, totalUnits, 8)}] ${completedUnits}/${totalUnits} (${percent}%) 下一待办: ${state.nextUnitId || "-"}`;
|
|
103
|
+
} else {
|
|
104
|
+
workStepStatus = "ready";
|
|
105
|
+
workStepLabel = "[就绪]";
|
|
106
|
+
workStepDetail = `计划已就绪,共 ${totalUnits} 个单元待编码`;
|
|
107
|
+
}
|
|
108
|
+
} else if (hasPlan) {
|
|
109
|
+
workStepStatus = "ready";
|
|
110
|
+
workStepLabel = "[就绪]";
|
|
111
|
+
workStepDetail = "计划文档已存在,随时可启动自主干活";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
steps.push({
|
|
115
|
+
number: "03",
|
|
116
|
+
id: "03-work",
|
|
117
|
+
title: "自主编码与循环推进",
|
|
118
|
+
status: workStepStatus,
|
|
119
|
+
statusLabel: workStepLabel,
|
|
120
|
+
detail: workStepDetail,
|
|
121
|
+
isCurrent: currentStepId === "03-work",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Step 04: 04-review
|
|
125
|
+
const isWorkDone = totalUnits > 0 && remainingUnits === 0;
|
|
126
|
+
steps.push({
|
|
127
|
+
number: "04",
|
|
128
|
+
id: "04-review",
|
|
129
|
+
title: "代码质量与规格审查",
|
|
130
|
+
status: isWorkDone ? "ready" : state.stage === "completed" ? "completed" : "pending",
|
|
131
|
+
statusLabel: state.stage === "completed" ? "[已完成]" : isWorkDone ? "[就绪]" : "[待办]",
|
|
132
|
+
detail: isWorkDone
|
|
133
|
+
? "单元实现已完毕,可启动全量代码审查与回归验证"
|
|
134
|
+
: state.stage === "completed"
|
|
135
|
+
? "审查已完成,项目规格与质量达标"
|
|
136
|
+
: "建议在 Implementation Units 验证后审查",
|
|
137
|
+
isCurrent: currentStepId === "04-review",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Step 05: 05-learn
|
|
141
|
+
const hasSolutions = state.solutions.length > 0;
|
|
142
|
+
steps.push({
|
|
143
|
+
number: "05",
|
|
144
|
+
id: "05-learn",
|
|
145
|
+
title: "知识复盘与经验沉淀",
|
|
146
|
+
status: hasSolutions ? "completed" : state.stage === "completed" ? "ready" : "pending",
|
|
147
|
+
statusLabel: hasSolutions ? "[已沉淀]" : state.stage === "completed" ? "[就绪]" : "[待办]",
|
|
148
|
+
detail: hasSolutions
|
|
149
|
+
? `${state.solutions.length} 个避坑指南 (最新: ${state.latestSolution?.filename || "-"})`
|
|
150
|
+
: state.stage === "completed"
|
|
151
|
+
? "闭环已完成,可沉淀高价值非平凡解决方案"
|
|
152
|
+
: "在开发与审查完毕后进行",
|
|
153
|
+
isCurrent: currentStepId === "05-learn",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return steps;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Helper to calculate terminal display width considering CJK full-width characters and ANSI codes.
|
|
161
|
+
*/
|
|
162
|
+
export function getVisibleWidth(str: string): number {
|
|
163
|
+
let width = 0;
|
|
164
|
+
for (let i = 0; i < str.length; i++) {
|
|
165
|
+
const code = str.charCodeAt(i);
|
|
166
|
+
if (code === 0x1b && str[i + 1] === "[") {
|
|
167
|
+
const mIdx = str.indexOf("m", i);
|
|
168
|
+
if (mIdx !== -1) {
|
|
169
|
+
i = mIdx;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
175
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
176
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
177
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
178
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
179
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
180
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
181
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
182
|
+
code >= 0x10000
|
|
183
|
+
) {
|
|
184
|
+
width += 2;
|
|
185
|
+
} else {
|
|
186
|
+
width += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return width;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Truncates string to a maximum visible width, appending an ellipsis if truncated.
|
|
194
|
+
*/
|
|
195
|
+
export function truncateToVisibleWidth(str: string, maxWidth: number): string {
|
|
196
|
+
if (getVisibleWidth(str) <= maxWidth) return str;
|
|
197
|
+
let currentWidth = 0;
|
|
198
|
+
let result = "";
|
|
199
|
+
for (let i = 0; i < str.length; i++) {
|
|
200
|
+
const char = str[i];
|
|
201
|
+
const code = str.charCodeAt(i);
|
|
202
|
+
const charWidth =
|
|
203
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
204
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
205
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
206
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
207
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
208
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
209
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
210
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
211
|
+
code >= 0x10000
|
|
212
|
+
? 2
|
|
213
|
+
: 1;
|
|
214
|
+
|
|
215
|
+
if (currentWidth + charWidth + 1 > maxWidth) {
|
|
216
|
+
result += "…";
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
result += char;
|
|
220
|
+
currentWidth += charWidth;
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function renderBoxHeader(left: string, right = "", width = 76): string {
|
|
226
|
+
const inner = width - 2;
|
|
227
|
+
const l = `─ ${left} `;
|
|
228
|
+
const r = right ? ` ${right} ─` : "─";
|
|
229
|
+
const fixed = getVisibleWidth(l) + getVisibleWidth(r);
|
|
230
|
+
const fill = Math.max(1, inner - fixed);
|
|
231
|
+
return `╭${l}${"─".repeat(fill)}${r}╮`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function renderBoxLine(content: string, width = 76): string {
|
|
235
|
+
const inner = width - 4;
|
|
236
|
+
const truncated = truncateToVisibleWidth(content, inner);
|
|
237
|
+
const visibleLen = getVisibleWidth(truncated);
|
|
238
|
+
const pad = Math.max(0, inner - visibleLen);
|
|
239
|
+
return `│ ${truncated}${" ".repeat(pad)} │`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function renderBoxFooter(text = "", width = 76): string {
|
|
243
|
+
const inner = width - 2;
|
|
244
|
+
if (!text) return `╰${"─".repeat(inner)}╯`;
|
|
245
|
+
const t = `─ ${text} `;
|
|
246
|
+
const fill = Math.max(1, inner - getVisibleWidth(t));
|
|
247
|
+
return `╰${t}${"─".repeat(fill)}╯`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Builds a structured, visually polished compact ASCII status dashboard (Goal-style, 7 lines).
|
|
252
|
+
*/
|
|
253
|
+
export function buildWorkflowDashboard(
|
|
254
|
+
state: WorkflowStateResult,
|
|
255
|
+
workStatus: WorkStatus,
|
|
256
|
+
boxWidth = 76,
|
|
257
|
+
): string {
|
|
258
|
+
const steps = deriveWorkflowSteps(state, workStatus);
|
|
259
|
+
const currentStep = steps.find((s) => s.isCurrent) || steps[0];
|
|
260
|
+
|
|
261
|
+
const totalUnits = state.totalUnitsCount ?? workStatus.allUnits.length;
|
|
262
|
+
const completedUnits = state.completedUnitsCount ?? workStatus.completedUnits.length;
|
|
263
|
+
const percent = totalUnits > 0 ? Math.round((completedUnits / totalUnits) * 100) : 0;
|
|
264
|
+
|
|
265
|
+
const lines: string[] = [];
|
|
266
|
+
|
|
267
|
+
// 1. Header: 1 line
|
|
268
|
+
const progressSummary =
|
|
269
|
+
totalUnits > 0
|
|
270
|
+
? `[${renderProgressBar(completedUnits, totalUnits, 8)}] ${completedUnits}/${totalUnits} (${percent}%)`
|
|
271
|
+
: "";
|
|
272
|
+
const leftTitle = `Workflow Dashboard ─ [${currentStep.id} ${currentStep.statusLabel}]`;
|
|
273
|
+
lines.push(renderBoxHeader(leftTitle, progressSummary, boxWidth));
|
|
274
|
+
|
|
275
|
+
// 2. Exactly 5 steps: 5 lines (1 clean line per step)
|
|
276
|
+
for (const step of steps) {
|
|
277
|
+
const marker = step.isCurrent ? "▶" : step.status === "completed" ? "✓" : "·";
|
|
278
|
+
const rowContent = ` ${marker} [${step.number}] ${step.id.padEnd(14)} ${step.statusLabel.padEnd(8)} · ${step.detail}`;
|
|
279
|
+
lines.push(renderBoxLine(rowContent, boxWidth));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Footer: 1 line
|
|
283
|
+
lines.push(renderBoxFooter("Esc/Ctrl+C: 暂停 · 继续: 恢复 · /workflow", boxWidth));
|
|
284
|
+
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Builds ultra-compact 1-line display for status bar or above-editor widget.
|
|
290
|
+
*/
|
|
291
|
+
export function buildWorkflowWidgetLines(
|
|
292
|
+
state: WorkflowStateResult,
|
|
293
|
+
workStatus: WorkStatus,
|
|
294
|
+
): string[] {
|
|
295
|
+
const steps = deriveWorkflowSteps(state, workStatus);
|
|
296
|
+
const currentStep = steps.find((s) => s.isCurrent) || steps[0];
|
|
297
|
+
|
|
298
|
+
const pipeline = steps
|
|
299
|
+
.map((s) => {
|
|
300
|
+
const sym = s.isCurrent ? "▶" : s.status === "completed" ? "✓" : "·";
|
|
301
|
+
return `${s.number}${sym}`;
|
|
302
|
+
})
|
|
303
|
+
.join(" ");
|
|
304
|
+
|
|
305
|
+
const totalUnits = state.totalUnitsCount ?? workStatus.allUnits.length;
|
|
306
|
+
const completedUnits = state.completedUnitsCount ?? workStatus.completedUnits.length;
|
|
307
|
+
const pct = totalUnits > 0 ? Math.round((completedUnits / totalUnits) * 100) : 0;
|
|
308
|
+
|
|
309
|
+
if (workStatus.isActive) {
|
|
310
|
+
const cur = workStatus.currentUnit ? ` 当前: ${workStatus.currentUnit}` : "";
|
|
311
|
+
return [
|
|
312
|
+
`⚡ [${pipeline}] 03-work [${renderProgressBar(completedUnits, totalUnits, 6)}] ${completedUnits}/${totalUnits} (${pct}%)${cur} | [Esc 暂停]`,
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (workStatus.lastMessage && workStatus.lastMessage.includes("暂停")) {
|
|
317
|
+
return [
|
|
318
|
+
`⏸️ [${pipeline}] 03-work 已暂停 [${completedUnits}/${totalUnits} 单元] | 输入“继续”恢复 | /workflow 查看看板`,
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
`🎯 [${pipeline}] 当前: [${currentStep.number}] ${currentStep.id} ${currentStep.statusLabel} | /workflow 查看看板`,
|
|
324
|
+
];
|
|
325
|
+
}
|
|
@@ -22,8 +22,10 @@ const tsFiles = [
|
|
|
22
22
|
"src/tools/artifact-helper",
|
|
23
23
|
"src/tools/session-checkpoint",
|
|
24
24
|
"src/tools/workflow-state",
|
|
25
|
+
"src/tools/workflow-dashboard",
|
|
25
26
|
"src/filters/bash-output-filter",
|
|
26
27
|
"src/filters/read-output-filter",
|
|
28
|
+
"src/driver/trigger-matcher",
|
|
27
29
|
"src/driver/work-loop-driver",
|
|
28
30
|
];
|
|
29
31
|
|
|
@@ -49,6 +51,13 @@ const { detectWorkflowState } = await import(
|
|
|
49
51
|
pathToFileURL(path.join(tempBuildDir, "src/tools/workflow-state.js"))
|
|
50
52
|
);
|
|
51
53
|
|
|
54
|
+
const {
|
|
55
|
+
deriveWorkflowSteps,
|
|
56
|
+
buildWorkflowDashboard,
|
|
57
|
+
buildWorkflowWidgetLines,
|
|
58
|
+
renderProgressBar,
|
|
59
|
+
} = await import(pathToFileURL(path.join(tempBuildDir, "src/tools/workflow-dashboard.js")));
|
|
60
|
+
|
|
52
61
|
const { filterBashOutput } = await import(
|
|
53
62
|
pathToFileURL(path.join(tempBuildDir, "src/filters/bash-output-filter.js"))
|
|
54
63
|
);
|
|
@@ -61,7 +70,7 @@ const { default: workflowExtension } = await import(
|
|
|
61
70
|
pathToFileURL(path.join(tempBuildDir, "index.js"))
|
|
62
71
|
);
|
|
63
72
|
|
|
64
|
-
const { parsePlanUnits, WorkLoopDriver } = await import(
|
|
73
|
+
const { parsePlanUnits, WorkLoopDriver, isExplicit03WorkTrigger } = await import(
|
|
65
74
|
pathToFileURL(path.join(tempBuildDir, "src/driver/work-loop-driver.js"))
|
|
66
75
|
);
|
|
67
76
|
|
|
@@ -253,7 +262,7 @@ test("read_output_filter: compresses lockfiles and large files", () => {
|
|
|
253
262
|
assert.ok(r1.output.includes("totalPackagesCount"));
|
|
254
263
|
});
|
|
255
264
|
|
|
256
|
-
test("workflowExtension: registers tools and command cleanly", () => {
|
|
265
|
+
test("workflowExtension: registers tools and command cleanly", async () => {
|
|
257
266
|
const registeredTools = new Map();
|
|
258
267
|
const registeredCommands = new Map();
|
|
259
268
|
const listeners = new Map();
|
|
@@ -280,6 +289,14 @@ test("workflowExtension: registers tools and command cleanly", () => {
|
|
|
280
289
|
assert.ok(listeners.has("before_agent_start"));
|
|
281
290
|
assert.ok(listeners.has("input"));
|
|
282
291
|
assert.ok(listeners.has("agent_settled"));
|
|
292
|
+
|
|
293
|
+
// Verify input handler passes through standard inputs and handles pause
|
|
294
|
+
const inputHandler = listeners.get("input");
|
|
295
|
+
const resPassThrough = await inputHandler({ text: "/skill:01-brainstorm", source: "interactive" }, {});
|
|
296
|
+
assert.deepEqual(resPassThrough, { action: "continue" });
|
|
297
|
+
|
|
298
|
+
const resNormal = await inputHandler({ text: "1. 选第一个方案", source: "interactive" }, {});
|
|
299
|
+
assert.deepEqual(resNormal, { action: "continue" });
|
|
283
300
|
});
|
|
284
301
|
|
|
285
302
|
test("parsePlanUnits: correctly extracts various unit headers and check-boxes", () => {
|
|
@@ -452,3 +469,257 @@ test("WorkLoopDriver: records failure history and triggers Stop-The-Line valve o
|
|
|
452
469
|
|
|
453
470
|
driver.cancelTimer();
|
|
454
471
|
});
|
|
472
|
+
|
|
473
|
+
test("isExplicit03WorkTrigger: accurately identifies 03-work invocations vs harmless inputs", async () => {
|
|
474
|
+
// Should match explicit 03 triggers
|
|
475
|
+
assert.equal(isExplicit03WorkTrigger("/skill:03-work"), true);
|
|
476
|
+
assert.equal(isExplicit03WorkTrigger("/skill:03"), true);
|
|
477
|
+
assert.equal(isExplicit03WorkTrigger("开始干活"), true);
|
|
478
|
+
assert.equal(isExplicit03WorkTrigger("开始03"), true);
|
|
479
|
+
assert.equal(isExplicit03WorkTrigger("开始实现"), true);
|
|
480
|
+
assert.equal(isExplicit03WorkTrigger("执行03"), true);
|
|
481
|
+
assert.equal(isExplicit03WorkTrigger("继续干活"), true);
|
|
482
|
+
assert.equal(isExplicit03WorkTrigger("恢复干活"), true);
|
|
483
|
+
assert.equal(isExplicit03WorkTrigger("resume work"), true);
|
|
484
|
+
assert.equal(isExplicit03WorkTrigger("【03-work 自主循环驱动引擎 · 自动化续跑指令】"), true);
|
|
485
|
+
|
|
486
|
+
// Plain numbers must NEVER trigger (must require /skill: or explicit phrase)
|
|
487
|
+
assert.equal(isExplicit03WorkTrigger("03"), false);
|
|
488
|
+
assert.equal(isExplicit03WorkTrigger("/03"), false);
|
|
489
|
+
|
|
490
|
+
// Must NEVER match other skills or normal dialogue (prevents false auto-starts!)
|
|
491
|
+
assert.equal(isExplicit03WorkTrigger("00"), false);
|
|
492
|
+
assert.equal(isExplicit03WorkTrigger("/skill:00-next"), false);
|
|
493
|
+
assert.equal(isExplicit03WorkTrigger("01"), false);
|
|
494
|
+
assert.equal(isExplicit03WorkTrigger("/skill:01-brainstorm"), false);
|
|
495
|
+
assert.equal(isExplicit03WorkTrigger("02"), false);
|
|
496
|
+
assert.equal(isExplicit03WorkTrigger("/skill:02-plan"), false);
|
|
497
|
+
assert.equal(isExplicit03WorkTrigger("04"), false);
|
|
498
|
+
assert.equal(isExplicit03WorkTrigger("/skill:04-review"), false);
|
|
499
|
+
assert.equal(isExplicit03WorkTrigger("05"), false);
|
|
500
|
+
assert.equal(isExplicit03WorkTrigger("/skill:05-learn"), false);
|
|
501
|
+
assert.equal(isExplicit03WorkTrigger("我想了解一下03步骤是什么"), false);
|
|
502
|
+
assert.equal(isExplicit03WorkTrigger("继续讨论一下需求细节"), false);
|
|
503
|
+
assert.equal(isExplicit03WorkTrigger("请帮我恢复之前被删除的代码"), false);
|
|
504
|
+
assert.equal(isExplicit03WorkTrigger(""), false);
|
|
505
|
+
assert.equal(isExplicit03WorkTrigger(" "), false);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("workflow-dashboard: derives all 5 steps and renders goal-style status dashboard", async () => {
|
|
509
|
+
const mockState = {
|
|
510
|
+
repoRoot: "/test/repo",
|
|
511
|
+
hasArtifacts: true,
|
|
512
|
+
stage: "planned",
|
|
513
|
+
recommendedSkill: "03-work",
|
|
514
|
+
recommendationReason: "计划已就绪",
|
|
515
|
+
brainstorms: [{ filename: "requirements.md", relativePath: "docs/brainstorms/requirements.md", mtimeMs: 100 }],
|
|
516
|
+
plans: [{ filename: "plan.md", relativePath: "docs/plans/plan.md", mtimeMs: 200 }],
|
|
517
|
+
solutions: [],
|
|
518
|
+
checkpoints: [],
|
|
519
|
+
latestBrainstorm: { filename: "requirements.md", relativePath: "docs/brainstorms/requirements.md", mtimeMs: 100 },
|
|
520
|
+
latestPlan: { filename: "plan.md", relativePath: "docs/plans/plan.md", mtimeMs: 200 },
|
|
521
|
+
totalUnitsCount: 4,
|
|
522
|
+
completedUnitsCount: 1,
|
|
523
|
+
remainingUnitsCount: 3,
|
|
524
|
+
nextUnitId: "Unit 1",
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const mockWorkStatus = {
|
|
528
|
+
isActive: true,
|
|
529
|
+
planPath: "/test/repo/docs/plans/plan.md",
|
|
530
|
+
planSlug: "plan",
|
|
531
|
+
allUnits: ["Unit 0", "Unit 1", "Unit 2", "Unit 3"],
|
|
532
|
+
completedUnits: ["Unit 0"],
|
|
533
|
+
remainingUnits: ["Unit 1", "Unit 2", "Unit 3"],
|
|
534
|
+
currentUnit: "Unit 1",
|
|
535
|
+
failedUnit: null,
|
|
536
|
+
consecutiveFailures: 0,
|
|
537
|
+
currentRunCount: 1,
|
|
538
|
+
maxRuns: 50,
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// 1. Check all 5 steps derivation
|
|
542
|
+
const steps = deriveWorkflowSteps(mockState, mockWorkStatus);
|
|
543
|
+
assert.equal(steps.length, 5);
|
|
544
|
+
assert.equal(steps[0].number, "01");
|
|
545
|
+
assert.equal(steps[0].id, "01-brainstorm");
|
|
546
|
+
assert.equal(steps[0].status, "completed");
|
|
547
|
+
|
|
548
|
+
assert.equal(steps[1].number, "02");
|
|
549
|
+
assert.equal(steps[1].id, "02-plan");
|
|
550
|
+
assert.equal(steps[1].status, "completed");
|
|
551
|
+
|
|
552
|
+
assert.equal(steps[2].number, "03");
|
|
553
|
+
assert.equal(steps[2].id, "03-work");
|
|
554
|
+
assert.equal(steps[2].status, "in_progress");
|
|
555
|
+
assert.equal(steps[2].isCurrent, true);
|
|
556
|
+
|
|
557
|
+
assert.equal(steps[3].number, "04");
|
|
558
|
+
assert.equal(steps[3].id, "04-review");
|
|
559
|
+
|
|
560
|
+
assert.equal(steps[4].number, "05");
|
|
561
|
+
assert.equal(steps[4].id, "05-learn");
|
|
562
|
+
|
|
563
|
+
// 2. Check full dashboard rendering
|
|
564
|
+
const dashboard = buildWorkflowDashboard(mockState, mockWorkStatus);
|
|
565
|
+
assert.ok(dashboard.includes("Workflow Dashboard"));
|
|
566
|
+
assert.ok(dashboard.includes("▶ [03] 03-work"));
|
|
567
|
+
assert.ok(dashboard.includes("[01] 01-brainstorm"));
|
|
568
|
+
assert.ok(dashboard.includes("[02] 02-plan"));
|
|
569
|
+
assert.ok(dashboard.includes("[04] 04-review"));
|
|
570
|
+
assert.ok(dashboard.includes("[05] 05-learn"));
|
|
571
|
+
assert.ok(dashboard.includes("Esc/Ctrl+C"));
|
|
572
|
+
assert.ok(dashboard.includes("单元进度:"));
|
|
573
|
+
|
|
574
|
+
// 3. Check progress bar helper
|
|
575
|
+
assert.equal(renderProgressBar(2, 4, 8), "████░░░░");
|
|
576
|
+
assert.equal(renderProgressBar(4, 4, 8), "████████");
|
|
577
|
+
assert.equal(renderProgressBar(0, 4, 8), "░░░░░░░░");
|
|
578
|
+
|
|
579
|
+
// 4. Check widget lines
|
|
580
|
+
const widgetLines = buildWorkflowWidgetLines(mockState, mockWorkStatus);
|
|
581
|
+
assert.ok(widgetLines[0].includes("03-work"));
|
|
582
|
+
assert.ok(widgetLines[0].includes("Unit 1"));
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("WorkLoopDriver: Esc/Abort interruption safely disarms autonomous driving", async () => {
|
|
586
|
+
const testRoot = path.join(tempBuildDir, "loop-interrupt-test-" + Date.now());
|
|
587
|
+
const plansDir = path.join(testRoot, "docs", "plans");
|
|
588
|
+
await mkdir(plansDir, { recursive: true });
|
|
589
|
+
|
|
590
|
+
const planFile = path.join(plansDir, "interrupt-plan.md");
|
|
591
|
+
await writeFile(planFile, "# Plan\n### Unit 0 — Alpha\n### Unit 1 — Beta", "utf-8");
|
|
592
|
+
|
|
593
|
+
const driver = new WorkLoopDriver(testRoot);
|
|
594
|
+
await driver.start(planFile);
|
|
595
|
+
assert.equal(driver.getStatus().isActive, true);
|
|
596
|
+
|
|
597
|
+
// 1. Simulate pause triggered by Esc / user action
|
|
598
|
+
await driver.pause("用户按 Esc 中断");
|
|
599
|
+
assert.equal(driver.getStatus().isActive, false);
|
|
600
|
+
assert.ok(driver.getStatus().lastMessage.includes("用户按 Esc 中断"));
|
|
601
|
+
|
|
602
|
+
// 2. Simulate agent_settled called while paused -> must NOT start next turn
|
|
603
|
+
let sendCalled = false;
|
|
604
|
+
const mockPi = {
|
|
605
|
+
sendUserMessage() {
|
|
606
|
+
sendCalled = true;
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
const mockCtx = {
|
|
610
|
+
signal: undefined,
|
|
611
|
+
ui: { notify() {}, setStatus() {} },
|
|
612
|
+
};
|
|
613
|
+
await driver.onAgentSettled(mockCtx, mockPi);
|
|
614
|
+
assert.equal(sendCalled, false);
|
|
615
|
+
assert.equal(driver.getStatus().isActive, false);
|
|
616
|
+
|
|
617
|
+
// 3. Test onAgentSettled with ctx.signal.aborted
|
|
618
|
+
await driver.start(planFile);
|
|
619
|
+
assert.equal(driver.getStatus().isActive, true);
|
|
620
|
+
const abortedCtx = {
|
|
621
|
+
signal: { aborted: true },
|
|
622
|
+
ui: { notify() {}, setStatus() {} },
|
|
623
|
+
};
|
|
624
|
+
await driver.onAgentSettled(abortedCtx, mockPi);
|
|
625
|
+
assert.equal(driver.getStatus().isActive, false);
|
|
626
|
+
assert.equal(sendCalled, false);
|
|
627
|
+
|
|
628
|
+
// 4. Test onAgentSettled with last assistant message aborted (Ctrl+C / Esc aftermath)
|
|
629
|
+
await driver.start(planFile);
|
|
630
|
+
assert.equal(driver.getStatus().isActive, true);
|
|
631
|
+
const sessionAbortedCtx = {
|
|
632
|
+
signal: undefined,
|
|
633
|
+
sessionManager: {
|
|
634
|
+
getBranch() {
|
|
635
|
+
return [
|
|
636
|
+
{ type: "message", message: { role: "user", content: "hello" } },
|
|
637
|
+
{ type: "message", message: { role: "assistant", stopReason: "aborted", content: "stopping..." } },
|
|
638
|
+
];
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
ui: { notify() {}, setStatus() {} },
|
|
642
|
+
};
|
|
643
|
+
await driver.onAgentSettled(sessionAbortedCtx, mockPi);
|
|
644
|
+
assert.equal(driver.getStatus().isActive, false);
|
|
645
|
+
assert.equal(sendCalled, false);
|
|
646
|
+
assert.ok(driver.getStatus().lastMessage.includes("中断"));
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("E2E Lifecycle: 01 brainstorm -> 02 plan -> 01 rollback -> 03 work -> compact dashboard & widget", async () => {
|
|
650
|
+
const testRoot = path.join(tempBuildDir, "e2e-workflow-" + Date.now());
|
|
651
|
+
const brainstormsDir = path.join(testRoot, "docs", "brainstorms");
|
|
652
|
+
const plansDir = path.join(testRoot, "docs", "plans");
|
|
653
|
+
await mkdir(brainstormsDir, { recursive: true });
|
|
654
|
+
await mkdir(plansDir, { recursive: true });
|
|
655
|
+
|
|
656
|
+
// 1. Initial State: No artifacts -> recommends 01-brainstorm
|
|
657
|
+
let state = await detectWorkflowState(testRoot);
|
|
658
|
+
assert.equal(state.recommendedSkill, "01-brainstorm");
|
|
659
|
+
|
|
660
|
+
// 2. User creates 01 brainstorm artifact
|
|
661
|
+
const bsFile = path.join(brainstormsDir, "2026-09-18-auth-requirements.md");
|
|
662
|
+
await writeFile(bsFile, "# Auth Requirements\nScope: login, register", "utf-8");
|
|
663
|
+
|
|
664
|
+
state = await detectWorkflowState(testRoot);
|
|
665
|
+
assert.equal(state.recommendedSkill, "02-plan");
|
|
666
|
+
assert.equal(state.brainstorms.length, 1);
|
|
667
|
+
|
|
668
|
+
// 3. User creates 02 plan artifact
|
|
669
|
+
const planFile = path.join(plansDir, "2026-09-18-auth-plan.md");
|
|
670
|
+
await writeFile(
|
|
671
|
+
planFile,
|
|
672
|
+
"# Auth Plan\n### Unit 0 — DB Schema\n### Unit 1 — API Route\n### Unit 2 — Tests",
|
|
673
|
+
"utf-8",
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
state = await detectWorkflowState(testRoot);
|
|
677
|
+
assert.equal(state.recommendedSkill, "03-work");
|
|
678
|
+
assert.equal(state.totalUnitsCount, 3);
|
|
679
|
+
|
|
680
|
+
// 4. User is in 02, realizes requirements need changes -> Rolls back to 01 via /skill:01-brainstorm
|
|
681
|
+
// In standard Pi, user runs /skill:01-brainstorm to revisit requirements
|
|
682
|
+
await writeFile(bsFile, "# Auth Requirements\nScope: login, register, oauth2", "utf-8");
|
|
683
|
+
|
|
684
|
+
// 5. User returns to 02 via /skill:02-plan and updates the plan
|
|
685
|
+
await writeFile(
|
|
686
|
+
planFile,
|
|
687
|
+
"# Auth Plan\n### Unit 0 — DB Schema\n### Unit 1 — API Route & OAuth2\n### Unit 2 — Tests",
|
|
688
|
+
"utf-8",
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
state = await detectWorkflowState(testRoot);
|
|
692
|
+
assert.equal(state.recommendedSkill, "03-work");
|
|
693
|
+
|
|
694
|
+
// 6. User enters 03 -> starts autonomous work via /skill:03-work
|
|
695
|
+
assert.equal(isExplicit03WorkTrigger("/skill:03-work"), true);
|
|
696
|
+
const driver = new WorkLoopDriver(testRoot);
|
|
697
|
+
const startRes = await driver.start(planFile);
|
|
698
|
+
assert.equal(startRes.success, true);
|
|
699
|
+
assert.equal(startRes.status.isActive, true);
|
|
700
|
+
|
|
701
|
+
// 7. Verify Dashboard height is strictly 7 lines
|
|
702
|
+
const dashboard = buildWorkflowDashboard(state, driver.getStatus());
|
|
703
|
+
const dashboardLines = dashboard.trim().split("\n");
|
|
704
|
+
assert.equal(dashboardLines.length, 7, "Dashboard must strictly occupy exactly 7 lines");
|
|
705
|
+
assert.ok(dashboard.includes("▶ [03] 03-work"));
|
|
706
|
+
assert.ok(dashboard.includes("✓ [01] 01-brainstorm"));
|
|
707
|
+
assert.ok(dashboard.includes("✓ [02] 02-plan"));
|
|
708
|
+
|
|
709
|
+
// 8. Verify Widget height is strictly 1 line
|
|
710
|
+
const widgetLines = buildWorkflowWidgetLines(state, driver.getStatus());
|
|
711
|
+
assert.equal(widgetLines.length, 1, "Widget must strictly occupy exactly 1 line");
|
|
712
|
+
assert.ok(widgetLines[0].includes("03-work"));
|
|
713
|
+
assert.ok(widgetLines[0].includes("01✓ 02✓ 03▶"));
|
|
714
|
+
|
|
715
|
+
// 9. Manual interruption (Esc) stops the loop
|
|
716
|
+
await driver.pause("User hit Esc");
|
|
717
|
+
assert.equal(driver.getStatus().isActive, false);
|
|
718
|
+
|
|
719
|
+
// Post-pause widget is also strictly 1 line
|
|
720
|
+
const pausedWidgetLines = buildWorkflowWidgetLines(state, driver.getStatus());
|
|
721
|
+
assert.equal(pausedWidgetLines.length, 1);
|
|
722
|
+
assert.ok(pausedWidgetLines[0].includes("已暂停"));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
|