/** * mainflow.ts — Matt Pocock 主流程编排器(pi extension) * * 把主流程 setup → grill-with-docs → to-spec → to-tickets → implement → code-review * 做成一个六阶段状态机,绑定对应 pi 技能: * * stage skill * setup → setup-matt-pocock-skills (每个仓库一次) * grill → grill-with-docs (自动带 domain-modeling) * spec → to-spec (默认本地 .scratch/specs/) * tickets → to-tickets (默认本地 .scratch//issues/) * implement → implement (自动带 tdd) * review → code-review (双轴并行 subagent) * * 三件套: * 1. `flow` 工具(LLM 可调用):status / begin / advance / goto / reset * 状态存于工具 result details(分支安全,同 todo 示例) * 2. `/flow` 命令(用户可调用):查看/推进/跳转/重置;TUI 里可选阶段跳转 * 3. before_agent_start:流程激活时向 system prompt 注入当前阶段提示 * (阶段 + 应读的 SKILL.md 路径 + 完成后调 flow advance) * * 安装:复制到 ~/.pi/agent/extensions/mainflow.ts,新会话或 /reload 生效。 */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { Text } from "@earendil-works/pi-tui"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; interface Stage { key: string; skill: string; label: string; desc: string; } const STAGES: Stage[] = [ { key: "setup", skill: "setup-matt-pocock-skills", label: "Setup", desc: "配置 issue tracker / 域文档布局(每个仓库跑一次)", }, { key: "grill", skill: "grill-with-docs", label: "Grill", desc: "访谈打磨想法,写 CONTEXT.md 与 ADR(domain-modeling 自动加载)", }, { key: "spec", skill: "to-spec", label: "To Spec", desc: "把对话合成 spec 并发布(pi 默认本地 .scratch/specs/)", }, { key: "tickets", skill: "to-tickets", label: "To Tickets", desc: "拆成垂直切片票并标注阻塞边(pi 默认本地 .scratch/)", }, { key: "implement", skill: "implement", label: "Implement", desc: "按票实现,驱动 tdd(自动加载),收尾交 code-review", }, { key: "review", skill: "code-review", label: "Code Review", desc: "双轴并行 subagent 审查(Standards + Spec)", }, ]; const DONE_KEY = "done"; interface FlowState { started: boolean; stageIndex: number; } const HOME = homedir(); function skillPath(skill: string): string { const candidates = [ join(HOME, ".pi", "agent", "skills", skill, "SKILL.md"), join(HOME, ".agents", "skills", skill, "SKILL.md"), ]; return candidates.find((p) => existsSync(p)) ?? candidates[0]; } function readSkill(skill: string): string { try { return readFileSync(skillPath(skill), "utf8"); } catch { return ""; } } export default function (pi: ExtensionAPI) { let state: FlowState = { started: false, stageIndex: 0 }; const STATE_ENTRY = "mainflow:state"; const persist = () => { pi.appendEntry(STATE_ENTRY, { state: { ...state } }); }; const reconstruct = (ctx: ExtensionContext) => { state = { started: false, stageIndex: 0 }; // 优先:custom entries(命令与工具都通过 appendEntry 持久化,取最后一个) let found = false; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === STATE_ENTRY) { const data = entry.data as { state?: FlowState } | undefined; if (data?.state) { state = data.state; found = true; } } } if (found) return; // 兼容旧会话:工具 result details 回退 for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult" || msg.toolName !== "flow") continue; const details = msg.details as { state?: FlowState } | undefined; if (details?.state) state = details.state; } }; pi.on("session_start", async (_event, ctx) => reconstruct(ctx)); pi.on("session_tree", async (_event, ctx) => reconstruct(ctx)); interface StageStatus { started: boolean; done: boolean; stage: Stage | null; index: number; progress: string; } const stageStatus = (): StageStatus => { if (!state.started) return { started: false, done: false, stage: null, index: -1, progress: "未开始" }; if (state.stageIndex >= STAGES.length) { return { started: true, done: true, stage: null, index: STAGES.length, progress: `${STAGES.length}/${STAGES.length}` }; } const s = STAGES[state.stageIndex]; return { started: true, done: false, stage: s, index: state.stageIndex, progress: `${state.stageIndex + 1}/${STAGES.length}` }; }; const statusText = (): string => { const st = stageStatus(); if (!st.started) return "主流程未开始。运行 /flow begin,或让 agent 调用 flow 工具 action=begin。"; if (st.done) return "主流程已完成 ✅ 全部 6 个阶段(setup→grill→spec→tickets→implement→review)。运行 /flow reset 可重新开始。"; const s = st.stage!; const path = skillPath(s.skill); const preview = readSkill(s.skill).slice(0, 500); return [ `主流程阶段 ${st.progress}: **${s.label}**(${s.key})`, `阶段说明: ${s.desc}`, `绑定技能: ${s.skill}`, `技能文件: ${path}`, `下一步: 阅读该 SKILL.md 并执行;完成后调用 flow 工具 action=advance`, ``, preview ? `--- SKILL.md 预览 ---\n${preview}` : "(技能文件缺失,请先安装对应技能)", ].join("\n"); }; const FlowParams = Type.Object({ action: StringEnum(["status", "begin", "advance", "goto", "reset"] as const), stage: Type.Optional(Type.String({ description: "目标阶段 key(仅 goto 用)" })), }); pi.registerTool({ name: "flow", label: "Main Flow", description: "Matt Pocock 主流程编排(setup→grill→spec→tickets→implement→review)。actions: status(当前阶段+技能路径+SKILL 预览), begin(开始), advance(完成当前阶段并进入下一阶段), goto (跳转), reset(重置)。stage 可选值: " + STAGES.map((s) => s.key).join(", "), parameters: FlowParams, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { switch (params.action) { case "status": return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; case "begin": state = { started: true, stageIndex: 0 }; persist(); return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; case "advance": { if (!state.started) { return { content: [{ type: "text", text: "主流程未开始,请先 action=begin。" }], details: { state: { ...state } }, }; } state = { started: true, stageIndex: state.stageIndex + 1 }; persist(); return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; } case "goto": { if (!params.stage) { return { content: [{ type: "text", text: "goto 需要 stage 参数。" }], details: { state: { ...state } } }; } if (params.stage === DONE_KEY) { state = { started: true, stageIndex: STAGES.length }; persist(); return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; } const i = STAGES.findIndex((s) => s.key === params.stage); if (i < 0) { return { content: [ { type: "text", text: `未知阶段: ${params.stage}。可选: ${STAGES.map((s) => s.key).join(", ")}, ${DONE_KEY}`, }, ], details: { state: { ...state } }, }; } state = { started: true, stageIndex: i }; persist(); return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; } case "reset": state = { started: false, stageIndex: 0 }; persist(); return { content: [{ type: "text", text: "主流程已重置。" }], details: { state: { ...state } } }; default: return { content: [{ type: "text", text: statusText() }], details: { state: { ...state } } }; } }, renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("flow ")) + theme.fg("muted", args.action); if (args.stage) text += ` ${theme.fg("accent", args.stage)}`; return new Text(text, 0, 0); }, renderResult(result, _opts, theme, _context) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); }, }); pi.registerCommand("flow", { description: "主流程编排器:/flow 查看状态;/flow begin|advance|reset|goto ", getArgumentCompletions: (prefix) => { const opts = ["begin", "advance", "reset", ...STAGES.map((s) => `goto ${s.key}`), `goto ${DONE_KEY}`]; const filtered = opts.filter((o) => o.startsWith(prefix)); return filtered.length > 0 ? filtered.map((v) => ({ value: v, label: v })) : null; }, handler: async (args, ctx) => { const arg = args.trim(); let changed = false; if (arg === "begin") { state = { started: true, stageIndex: 0 }; changed = true; } else if (arg === "advance") { if (state.started) { state = { started: true, stageIndex: state.stageIndex + 1 }; changed = true; } } else if (arg === "reset") { state = { started: false, stageIndex: 0 }; changed = true; } else if (arg.startsWith("goto ")) { const key = arg.slice(5).trim(); if (key === DONE_KEY) { state = { started: true, stageIndex: STAGES.length }; changed = true; } else { const i = STAGES.findIndex((s) => s.key === key); if (i >= 0) { state = { started: true, stageIndex: i }; changed = true; } } } if (changed) persist(); // 带参数:明确反馈;begin/advance/goto 后自动触发 agent 回合 if (arg) { const msg = statusText(); if (ctx.mode === "tui") ctx.ui.notify(msg.slice(0, 500), "info"); const st = stageStatus(); if (changed && st.started && !st.done) { try { pi.sendMessage( { customType: "mainflow:kick", content: `[flow] 已进入阶段 ${st.stage?.key}(${st.stage?.label}),继续执行当前阶段。` , display: false, }, { triggerTurn: true, deliverAs: "steer" }, ); } catch { /* 非交互会话可能不可用,忽略 */ } } return; } // 无参数:TUI 选择器(查看/跳转),非 TUI 打印状态 if (ctx.mode === "tui") { const st = stageStatus(); const items = [ `▶ 当前: ${st.done ? "已完成" : st.stage ? `${st.stage.label}(${st.progress})` : "未开始"}` , ...STAGES.map((s) => `${s.key} — ${s.label}: ${s.desc}`), ]; const sel = await ctx.ui.select("Main Flow 主流程", items); if (sel && !sel.startsWith("▶")) { const key = sel.split(" — ")[0]; const i = STAGES.findIndex((s) => s.key === key); if (i >= 0) { state = { started: true, stageIndex: i }; persist(); ctx.ui.notify(`跳转到阶段 ${key}`, "info"); try { pi.sendMessage( { customType: "mainflow:kick", content: `[flow] 已跳转到阶段 ${key},继续执行当前阶段。`, display: false, }, { triggerTurn: true, deliverAs: "steer" }, ); } catch { /* 忽略 */ } } } } else { ctx.ui.notify(statusText().slice(0, 400), "info"); } }, }); pi.on("before_agent_start", async (event, ctx) => { reconstruct(ctx); if (!state.started) return; const st = stageStatus(); if (st.done || !st.stage) return; const s = st.stage; const path = skillPath(s.skill); const note = `\n\n[MAIN FLOW ACTIVE]\n` + `Current stage: ${s.key} (${s.label}) — ${s.desc}\n` + `Run the "${s.skill}" skill: read ${path} and follow it.\n` + `When the stage is complete, call the flow tool with action=advance.\n` + `Stage progress: ${st.progress}. Flow: setup→grill-with-docs→to-spec→to-tickets→implement→code-review.`; return { systemPrompt: event.systemPrompt + note }; }); }