@guanlin4924/devflow-pi 0.1.97 → 0.1.99

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 (25) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
  13. package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
  14. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
  15. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
  16. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
  17. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
  19. package/src/constants.ts +1 -1
  20. package/src/mcp/core.ts +23 -24
  21. package/src/mcp/server.ts +11 -12
  22. package/src/project-context.ts +9 -1
  23. package/src/project-initializer.ts +4 -4
  24. package/src/session-rollover.ts +10 -0
  25. package/src/workflow-diagnosis.ts +407 -0
@@ -1,7 +1,7 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { appendFile, mkdir, rename, unlink, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
4
+ import { homedir, tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
@@ -16,7 +16,21 @@ import { loadProjectContext } from "../../../src/project-context.ts";
16
16
  import { runProjectInitialization } from "../../../src/project-initializer.ts";
17
17
  import { projectControlDatabasePath } from "../../../src/paths.ts";
18
18
  import { generateProjectDocuments } from "../../../src/project-documents.ts";
19
+ import { preparedRolloverRevision } from "../../../src/session-rollover.ts";
19
20
  import { describeStartupIssue, renderStartupBriefing } from "../../../src/startup-briefing.ts";
21
+ import { PRODUCT_VERSION } from "../../../src/constants.ts";
22
+ import {
23
+ FIX_LABELS,
24
+ buildHelpBundle,
25
+ classifyNextStepPhrase,
26
+ describeControlFailure,
27
+ diagnoseWorkflow,
28
+ renderDiagnosis,
29
+ summarizeToolFailure,
30
+ type FixAction,
31
+ type ToolFailureRecord,
32
+ type WorkflowDiagnosis,
33
+ } from "../../../src/workflow-diagnosis.ts";
20
34
  import { exportInitializationSnapshot, importLegacyInitialization, switchInitializationAuthority } from "../../../src/project-migration.ts";
21
35
  import { migrateLegacyProjectLayout } from "../../../src/project-layout.ts";
22
36
  import {
@@ -37,6 +51,15 @@ import {
37
51
 
38
52
  export type DevflowIntent = "idle" | "explain-state" | "project-init" | "requirement-draft" | "workflow-execute";
39
53
 
54
+ /** Used only after the runtime receives an explicit user checkpoint approval. */
55
+ export function wireframeApprovalArgs(feature: string, revision: number): string[] {
56
+ return [
57
+ "--action", "approve", "--feature", feature,
58
+ "--expected-revision", String(revision), "--approved-by", "user",
59
+ "--all-screens", "--runtime-confirmed", "--write", "--json",
60
+ ];
61
+ }
62
+
40
63
  export type ControlPhase = "idle" | "planning" | "awaiting-approval" | "waiting-user" | "executing" | "blocked";
41
64
 
42
65
  export type SessionControlState = {
@@ -581,6 +604,10 @@ export function isVirtualStateTransitionResource(toolName: string, input: unknow
581
604
  return [record.path, record.uri, record.resource].some(value => value === "xd://devflow_transition");
582
605
  }
583
606
 
607
+ export const OUTSIDE_STEP_WRITE_REASON =
608
+ "当前不在受控的工作流步骤中:在这里写入 .devflow/planning/requirements 或 features 下的需求产物不会被工作流采纳,已拒绝。" +
609
+ "不要改用其他方式写入这些文件。请停止写入,把已有结论直接告诉用户,并请用户说「下一步」(还没有需求时先创建需求),由 Devflow 启动对应步骤后再写。";
610
+
584
611
  export function planningToolBlockReason(toolName: string): string {
585
612
  return `Devflow ${toolName} blocked: current intent is planning. Planning is read-only; present an Evidence Pack or plan and wait for explicit approval before writing or executing.`;
586
613
  }
@@ -612,6 +639,13 @@ export type ExploreHostRequest = {
612
639
  params?: Record<string, unknown>;
613
640
  };
614
641
 
642
+ class ExploreHostError extends Error {
643
+ constructor(message: string, readonly code?: string) {
644
+ super(message);
645
+ this.name = "ExploreHostError";
646
+ }
647
+ }
648
+
615
649
  /** Small JSON-lines client. The host owns Playwright; Core owns its lifetime. */
616
650
  export class ExploreHostManager {
617
651
  private child: any;
@@ -621,8 +655,10 @@ export class ExploreHostManager {
621
655
  private idleTimer?: ReturnType<typeof setTimeout>;
622
656
  private totalTimer?: ReturnType<typeof setTimeout>;
623
657
  private lastActivity = 0;
658
+ private exploreSecrets: Record<string, string> = {};
624
659
 
625
660
  get running(): boolean { return Boolean(this.child); }
661
+ get secrets(): Record<string, string> { return { ...this.exploreSecrets }; }
626
662
 
627
663
  async start(hostPath: string, cwd: string, environmentFile: string, secrets: Record<string, string>): Promise<void> {
628
664
  if (this.child) throw new Error("an explore session is already active");
@@ -635,6 +671,7 @@ export class ExploreHostManager {
635
671
  env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE = environmentFile;
636
672
  for (const [name, value] of Object.entries(secrets)) env[name] = value;
637
673
  this.child = Bun.spawn(["node", hostPath], { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true });
674
+ this.exploreSecrets = { ...secrets };
638
675
  void new Response(this.child.stderr).text();
639
676
  this.lastActivity = Date.now();
640
677
  this.readerTask = this.readResponses(this.child.stdout);
@@ -662,6 +699,7 @@ export class ExploreHostManager {
662
699
  const child = this.child;
663
700
  if (!child) return;
664
701
  this.child = undefined;
702
+ this.exploreSecrets = {};
665
703
  if (this.idleTimer) clearTimeout(this.idleTimer);
666
704
  if (this.totalTimer) clearTimeout(this.totalTimer);
667
705
  for (const pending of this.pending.values()) pending.reject(new Error(reason));
@@ -706,7 +744,10 @@ export class ExploreHostManager {
706
744
  if (!waiter) continue;
707
745
  this.pending.delete(response.id);
708
746
  if (response.ok === true) waiter.resolve(response.result);
709
- else waiter.reject(new Error(String(response?.error?.message ?? "explore-host request failed")));
747
+ else waiter.reject(new ExploreHostError(
748
+ String(response?.error?.message ?? "explore-host request failed"),
749
+ typeof response?.error?.code === "string" ? response.error.code : undefined,
750
+ ));
710
751
  }
711
752
  }
712
753
  } finally {
@@ -742,6 +783,9 @@ type DoctorContext = {
742
783
  };
743
784
  };
744
785
 
786
+ type DevflowCommandHandler = Parameters<ExtensionAPI["registerCommand"]>[1]["handler"];
787
+ type DevflowCommandContext = Parameters<DevflowCommandHandler>[1];
788
+
745
789
  const CONTROL_PATHS = [
746
790
  ".devflow/planning/STATE.md",
747
791
  ".devflow/planning/.next-intent",
@@ -1117,8 +1161,31 @@ async function runProcess(
1117
1161
  };
1118
1162
  }
1119
1163
 
1164
+ /** Raw failures kept in memory for the user's help bundle; never sent to the model. */
1165
+ const recentToolFailures: ToolFailureRecord[] = [];
1166
+
1167
+ function recordToolFailure(tool: string, result: ProcessResult, summary: string): void {
1168
+ recentToolFailures.push({
1169
+ at: new Date().toISOString(),
1170
+ tool,
1171
+ exitCode: result.exitCode,
1172
+ summary,
1173
+ raw: (result.stderr || result.stdout).slice(0, 4000),
1174
+ });
1175
+ if (recentToolFailures.length > 20) recentToolFailures.splice(0, recentToolFailures.length - 20);
1176
+ }
1177
+
1120
1178
  function renderResult(result: ProcessResult, details: Record<string, unknown> = {}) {
1121
- const text = result.stdout || result.stderr || `Process exited with ${result.exitCode}`;
1179
+ // JSON output passes through unchanged; plain-text failures and empty output
1180
+ // become a Chinese summary instead of a raw stack trace.
1181
+ const summary = summarizeToolFailure(result);
1182
+ if (summary && !result.ok) recordToolFailure(String(details.action ?? "devflow"), result, summary);
1183
+ const raw = (result.stderr || result.stdout).trim();
1184
+ // Stack traces collapse to the summary; other plain-text failures may list
1185
+ // several problems, so the model still sees them in full.
1186
+ const keepRaw = summary !== null && !result.ok && raw !== "" && !summary.includes(raw)
1187
+ && !/:\d+:in [`']|Traceback \(most recent call last\)/u.test(raw);
1188
+ const text = summary === null ? (result.stdout || result.stderr) : keepRaw ? `${summary}\n\n原始信息:\n${raw.slice(0, 2000)}` : summary;
1122
1189
  return {
1123
1190
  content: [{ type: "text" as const, text }],
1124
1191
  details: { ...details, exitCode: result.exitCode, stderr: result.stderr || undefined },
@@ -1247,7 +1314,7 @@ function selectedWorkflowStep(snapshot: Record<string, unknown>): Record<string,
1247
1314
  * completed step. This predicate lets the runtime re-arm approval handling
1248
1315
  * from the Core-owned snapshot without granting approval by itself.
1249
1316
  */
1250
- function isReviewableWorkflowSnapshot(snapshot: Record<string, unknown>): boolean {
1317
+ function isReviewableWorkflowSnapshot(snapshot: Record<string, unknown>, options: { strictRegistry?: boolean } = {}): boolean {
1251
1318
  const status = snapshot.status;
1252
1319
  if (status !== "waiting_checkpoint" && status !== "not_started" && status !== "in_progress") return false;
1253
1320
  const step = snapshot.current_step;
@@ -1270,9 +1337,15 @@ function isReviewableWorkflowSnapshot(snapshot: Record<string, unknown>): boolea
1270
1337
  // Core has already validated and registered the artifact. Treat an
1271
1338
  // explicit false as invalid, but use exists + artifact_status as the
1272
1339
  // durable fallback when the field is absent.
1340
+ // sync_artifacts.rb keeps every output of an unfinished non-checkpoint
1341
+ // step at `planned`, so `planned` must not hide an output the step has
1342
+ // already written. advance_state.rb re-validates outputs on transition.
1343
+ // Resume disposition stays strict so an existing file never lets a step
1344
+ // start without contract activation.
1345
+ const rejected = options.strictRegistry ? ["failed", "blocked", "planned"] : ["failed", "blocked"];
1273
1346
  return record.exists === true
1274
1347
  && record.content_valid !== false
1275
- && !["failed", "blocked", "planned"].includes(String(record.artifact_status ?? ""));
1348
+ && !rejected.includes(String(record.artifact_status ?? ""));
1276
1349
  });
1277
1350
  }
1278
1351
 
@@ -1300,13 +1373,13 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
1300
1373
  if (!feature || !stepId || typeof revision !== "number" || !Number.isInteger(revision)) return "idle";
1301
1374
 
1302
1375
  if (action === "await_user_confirmation") {
1303
- return isReviewableWorkflowSnapshot(snapshot) ? "review" : "blocked";
1376
+ return isReviewableWorkflowSnapshot(snapshot, { strictRegistry: true }) ? "review" : "blocked";
1304
1377
  }
1305
1378
  // Parallel windows must be reconstructed by the Runtime even if a stale
1306
1379
  // join artifact happens to exist; branch lifecycle is the join authority.
1307
1380
  if (action === "run_parallel_window" || action === "route_failure_branch") return "execute";
1308
1381
  if (action === "run_next_step") {
1309
- return isReviewableWorkflowSnapshot(snapshot) ? "review" : "execute";
1382
+ return isReviewableWorkflowSnapshot(snapshot, { strictRegistry: true }) ? "review" : "execute";
1310
1383
  }
1311
1384
  return "blocked";
1312
1385
  }
@@ -1795,6 +1868,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1795
1868
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactExploreValue(item, secrets)]));
1796
1869
  return value;
1797
1870
  };
1871
+ const exploreScopePattern = /^(?:e\d+|aria-ref=e\d+|role=[^:\s]+(?::.+)?|testid=.+)$/;
1798
1872
 
1799
1873
  const exploreHostPath = (): string => {
1800
1874
  const candidates = [
@@ -2660,6 +2734,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2660
2734
  }
2661
2735
 
2662
2736
  let activeWorkflowTurn = false;
2737
+ // Set once the /devflow command is defined; lets the input handler and the
2738
+ // next-step entry reuse existing subcommands (start, abandon).
2739
+ let devflowCommandRef: ((args: string, ctx: DevflowCommandContext) => Promise<void>) | undefined;
2663
2740
  let parallelRunActive = false;
2664
2741
  let parallelContinuationQueued = false;
2665
2742
  let parallelDispatchSucceededThisTurn = false;
@@ -2865,6 +2942,17 @@ export default function devflowCore(pi: ExtensionAPI) {
2865
2942
  ctx.ui.notify("已保留当前步骤,未推进工作流。", "info");
2866
2943
  return true;
2867
2944
  }
2945
+ if (checkpoint && stepId === "wireframe-review") {
2946
+ const feature = inspected.snapshot.current_feature;
2947
+ if (typeof feature !== "string") return false;
2948
+ const approval = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/wireframe_commit.rb",
2949
+ wireframeApprovalArgs(feature, revision));
2950
+ if (!controlPlaneSucceeded(approval)) {
2951
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "原型确认写入失败" });
2952
+ ctx.ui.notify(`原型确认未写入:${approval.stderr || approval.stdout}`, "error");
2953
+ return true;
2954
+ }
2955
+ }
2868
2956
  const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
2869
2957
  "--write", "--json", "--expected-revision", String(revision),
2870
2958
  ...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId]),
@@ -2872,7 +2960,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2872
2960
  ]);
2873
2961
  if (!result.ok || !controlPlaneSucceeded(result)) {
2874
2962
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
2875
- ctx.ui.notify(`未推进:${result.stderr || result.stdout}`, "error");
2963
+ ctx.ui.notify(`未推进:${describeControlFailure(result)}\n可以说「下一步」重新诊断,或选择重新执行这一步来修复产物。`, "error");
2876
2964
  return true;
2877
2965
  }
2878
2966
  const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
@@ -2894,6 +2982,183 @@ export default function devflowCore(pi: ExtensionAPI) {
2894
2982
  return true;
2895
2983
  }
2896
2984
 
2985
+ async function diagnoseCurrentWorkflow(cwd: string): Promise<{
2986
+ snapshot: Record<string, unknown> | null;
2987
+ inspectError?: string;
2988
+ rolloverState: Record<string, unknown> | null;
2989
+ diagnosis: WorkflowDiagnosis;
2990
+ }> {
2991
+ const inspected = await inspectWorkflowWithRegistryRecovery(cwd);
2992
+ // A missing STATE.md is reported as structured JSON, not always with exit 0.
2993
+ const snapshot = inspected.snapshot ?? parseJsonOutput(inspected.result);
2994
+ const inspectError = snapshot ? undefined : (inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON");
2995
+ const rolloverState = snapshot?.current_feature
2996
+ ? parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]))
2997
+ : null;
2998
+ const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
2999
+ const diagnosis = diagnoseWorkflow({
3000
+ inspect: { ok: Boolean(snapshot), error: inspectError },
3001
+ snapshot,
3002
+ disposition: snapshot ? workflowResumeDisposition(snapshot) : "idle",
3003
+ outputsReady: snapshot ? isReviewableWorkflowSnapshot(snapshot) : false,
3004
+ rolloverPrepared: Boolean(rolloverState && typeof rolloverState.revision === "number"
3005
+ && rollover?.status === "prepared" && typeof rollover.id === "string"),
3006
+ });
3007
+ return { snapshot, inspectError, rolloverState, diagnosis };
3008
+ }
3009
+
3010
+ async function writeHelpBundle(
3011
+ ctx: DevflowCommandContext,
3012
+ current: Awaited<ReturnType<typeof diagnoseCurrentWorkflow>>,
3013
+ ): Promise<string> {
3014
+ const report = await runDoctor(ctx).catch(() => undefined);
3015
+ const createdAt = new Date().toISOString();
3016
+ const bundle = buildHelpBundle({
3017
+ version: PRODUCT_VERSION,
3018
+ createdAt,
3019
+ diagnosis: current.diagnosis,
3020
+ snapshot: current.snapshot,
3021
+ inspectError: current.inspectError,
3022
+ doctor: report?.checks,
3023
+ rollover: current.rolloverState,
3024
+ failures: recentToolFailures,
3025
+ root: ctx.cwd,
3026
+ home: homedir(),
3027
+ });
3028
+ // .devflow/runs is the git-ignored runtime output root.
3029
+ const relative = `.devflow/runs/diagnostics/help-${createdAt.replace(/[:.]/g, "-")}.json`;
3030
+ await mkdir(path.dirname(path.join(ctx.cwd, relative)), { recursive: true });
3031
+ await writeFile(path.join(ctx.cwd, relative), `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
3032
+ return relative;
3033
+ }
3034
+
3035
+ function nextStepHint(diagnosis: WorkflowDiagnosis): string {
3036
+ if (diagnosis.kind === "blocked") return "运行 /devflow fix 查看处理办法。";
3037
+ if (diagnosis.kind === "no-feature" || diagnosis.kind === "complete") return "说「下一步」或运行 /devflow start <需求名> 创建需求。";
3038
+ return "说「下一步」或运行 /devflow next 继续。";
3039
+ }
3040
+
3041
+ async function showWorkflowOverview(ctx: DevflowCommandContext): Promise<void> {
3042
+ const { diagnosis } = await diagnoseCurrentWorkflow(ctx.cwd);
3043
+ ctx.ui.notify(`${renderDiagnosis(diagnosis)}\n\n${nextStepHint(diagnosis)}`, diagnosis.kind === "blocked" ? "error" : "info");
3044
+ }
3045
+
3046
+ /** Fixes that start or advance a step; `/devflow fix` never offers them. */
3047
+ const ADVANCING_FIXES = new Set<FixAction>(["review-and-advance", "rerun-step", "resume-rollover", "create-feature"]);
3048
+
3049
+ /**
3050
+ * Single user entry: diagnose → offer a fix from the closed catalog → let
3051
+ * Core execute it → re-diagnose. The model is never asked to repair control
3052
+ * state; every write goes through the existing runtime/Core paths.
3053
+ */
3054
+ async function runNextStep(ctx: DevflowCommandContext, mode: "next" | "fix"): Promise<void> {
3055
+ if (activeWorkflowTurn || controlState.phase === "executing") {
3056
+ ctx.ui.notify("当前步骤正在执行,等这一轮结束后再说「下一步」。", "info");
3057
+ return;
3058
+ }
3059
+ if (mode === "next") {
3060
+ const report = await runDoctor(ctx);
3061
+ if (!report.ok) {
3062
+ ctx.ui.notify(`运行环境检查没有通过,暂时不能推进:\n${formatDoctorReport(report).slice(0, 6000)}`, "error");
3063
+ return;
3064
+ }
3065
+ }
3066
+ for (let attempt = 0; attempt < 4; attempt += 1) {
3067
+ const current = await diagnoseCurrentWorkflow(ctx.cwd);
3068
+ const { diagnosis, snapshot } = current;
3069
+ const summary = renderDiagnosis(diagnosis);
3070
+ if (diagnosis.kind === "execute" && snapshot) {
3071
+ if (mode === "fix") {
3072
+ ctx.ui.notify(`${summary}\n${nextStepHint(diagnosis)}`, "info");
3073
+ return;
3074
+ }
3075
+ await resumeInspectedWorkflow(ctx, snapshot, { doctorAlreadyPassed: true });
3076
+ return;
3077
+ }
3078
+ const fixes = diagnosis.diagnoses
3079
+ .flatMap(item => item.fixes)
3080
+ .filter(fix => mode === "next" || !ADVANCING_FIXES.has(fix));
3081
+ ctx.ui.notify(fixes.length > 0 || mode === "next" ? summary : `${summary}\n${nextStepHint(diagnosis)}`, diagnosis.kind === "blocked" ? "error" : "info");
3082
+ if (fixes.length === 0) return;
3083
+ const skip = "暂不处理";
3084
+ const choice = await ctx.ui.select("选择处理方式", [
3085
+ ...fixes.map(fix => ({ label: FIX_LABELS[fix].label, description: FIX_LABELS[fix].description })),
3086
+ { label: skip, description: "保持现状,不做任何修改" },
3087
+ ]);
3088
+ const fix = fixes.find(item => FIX_LABELS[item].label === choice);
3089
+ if (!fix) {
3090
+ ctx.ui.notify("已保持现状,未做任何修改。", "info");
3091
+ return;
3092
+ }
3093
+ if (fix === "create-feature") {
3094
+ const name = (await ctx.ui.input("创建需求", "需求名称,例如:私海线索转回公海保护"))?.trim();
3095
+ if (!name) {
3096
+ ctx.ui.notify("未输入需求名称,未创建需求。", "info");
3097
+ return;
3098
+ }
3099
+ await devflowCommandRef?.(`start ${name}`, ctx);
3100
+ return;
3101
+ }
3102
+ if (fix === "review-and-advance") {
3103
+ setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
3104
+ if (!(await confirmAndAdvanceWorkflowStep(ctx))) {
3105
+ ctx.ui.notify("无法打开推进确认:当前步骤的产物已经变化。请重新说「下一步」。", "error");
3106
+ }
3107
+ return;
3108
+ }
3109
+ if (fix === "revise-outputs") {
3110
+ setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
3111
+ ctx.ui.notify("已回到审查状态。请直接说明需要修改的内容;改完后说「批准执行」或「下一步」推进。", "info");
3112
+ return;
3113
+ }
3114
+ if (fix === "rerun-step" && snapshot) {
3115
+ const stepId = diagnosis.stepId ?? "当前步骤";
3116
+ setControlState(ctx, executingState("开工预检中"));
3117
+ void startWorkflowAfterPreflight(ctx, snapshot, "start", {
3118
+ doctorAlreadyPassed: true,
3119
+ executingLabel: `重新执行 ${stepId}`,
3120
+ }).catch(error => {
3121
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
3122
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
3123
+ });
3124
+ return;
3125
+ }
3126
+ if (fix === "resume-rollover" && snapshot) {
3127
+ const rollover = current.rolloverState?.rollover as Record<string, unknown> | undefined;
3128
+ if (typeof current.rolloverState?.revision !== "number" || typeof rollover?.id !== "string") return;
3129
+ pendingRollover = { snapshot, preparedRevision: current.rolloverState.revision, rolloverId: rollover.id };
3130
+ await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, ctx.sessionManager.getSessionId());
3131
+ setControlState(ctx, waitingUserState("正在恢复受控会话交接"));
3132
+ ctx.ui.notify("已恢复未完成的会话交接;当前回合结束后会切换到新会话并继续。", "info");
3133
+ return;
3134
+ }
3135
+ if (fix === "repair-registry") {
3136
+ const plan = snapshot?.recovery_plan as Record<string, unknown> | undefined;
3137
+ if (typeof plan?.expected_revision !== "number") return;
3138
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", [
3139
+ "--write", "--confirm", "--expected-revision", String(plan.expected_revision), "--json",
3140
+ ]);
3141
+ if (!controlPlaneSucceeded(result)) {
3142
+ ctx.ui.notify(`登记表修复失败:${describeControlFailure(result)}`, "error");
3143
+ return;
3144
+ }
3145
+ ctx.ui.notify("登记表已修复,未推进工作流。正在重新诊断……", "info");
3146
+ continue;
3147
+ }
3148
+ if (fix === "help-bundle") {
3149
+ const bundlePath = await writeHelpBundle(ctx, current);
3150
+ ctx.ui.notify(`已生成求助包:${bundlePath}\n只包含诊断信息(状态摘要、校验错误、最近的工具报错),不含源码。可以把这个文件发给维护者。`, "info");
3151
+ return;
3152
+ }
3153
+ if (fix === "cancel-feature") {
3154
+ await devflowCommandRef?.("abandon", ctx);
3155
+ return;
3156
+ }
3157
+ return;
3158
+ }
3159
+ ctx.ui.notify("自动修复后仍有问题。请运行 /devflow fix 并选择「生成求助包」。", "error");
3160
+ }
3161
+
2897
3162
  type ProjectControlCommandContext = {
2898
3163
  cwd: string;
2899
3164
  models: DoctorContext["models"];
@@ -3306,7 +3571,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3306
3571
  // sole authority: only the original source session may resume a prepared
3307
3572
  // transition, and only the already-recorded target may consume a claim.
3308
3573
  const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
3309
- const rolloverState = parseJsonResult(rolloverResult);
3574
+ let rolloverState = parseJsonResult(rolloverResult);
3310
3575
  let rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
3311
3576
  const currentSessionId = ctx.sessionManager.getSessionId();
3312
3577
  if (
@@ -3319,13 +3584,14 @@ export default function devflowCore(pi: ExtensionAPI) {
3319
3584
  ]);
3320
3585
  if (recovered.ok) {
3321
3586
  const refreshed = parseJsonResult(await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]));
3587
+ rolloverState = refreshed;
3322
3588
  rollover = refreshed?.rollover as Record<string, unknown> | undefined;
3323
3589
  }
3324
3590
  }
3325
3591
  if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && typeof rollover.id === "string") {
3326
3592
  const workflow = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
3327
- const expectedStateRevision = typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision : undefined;
3328
- if (workflow.result.ok && workflow.snapshot && expectedStateRevision !== undefined && expectedStateRevision + 1 === rolloverState.revision) {
3593
+ const preparedRevision = preparedRolloverRevision(rolloverState);
3594
+ if (workflow.result.ok && workflow.snapshot && preparedRevision !== undefined && workflow.snapshot.revision === preparedRevision) {
3329
3595
  pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
3330
3596
  await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
3331
3597
  ctx.ui.notify("已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
@@ -3492,7 +3758,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3492
3758
  if (event.source !== "interactive") return;
3493
3759
  const controlAction = classifyNaturalControlAction(event.text);
3494
3760
  const stepNote = event.text.trim();
3495
- if (controlState.intent === "workflow-execute" && !controlAction && stepNote && !stepNote.startsWith("/")) {
3761
+ if (controlState.intent === "workflow-execute" && !controlAction && stepNote && !stepNote.startsWith("/") && !classifyNextStepPhrase(stepNote)) {
3496
3762
  stepUserNotes.push(stepNote);
3497
3763
  }
3498
3764
  if (controlState.intent === "requirement-draft" && controlState.phase === "planning") {
@@ -3546,6 +3812,24 @@ export default function devflowCore(pi: ExtensionAPI) {
3546
3812
  }
3547
3813
  return { handled: true };
3548
3814
  }
3815
+ // Short phrases such as "下一步" open the same diagnose-and-fix entry as
3816
+ // /devflow next. They never approve by themselves: advancing still needs
3817
+ // the runtime confirmation dialog. "继续" is only taken over when a
3818
+ // feature is in progress so ordinary chat continuation reaches the model.
3819
+ const nextPhrase = classifyNextStepPhrase(event.text);
3820
+ if (nextPhrase && ctx.hasUI && ["idle", "waiting-user", "blocked"].includes(controlState.phase)) {
3821
+ let takeOver = nextPhrase === "next";
3822
+ if (nextPhrase === "continue") {
3823
+ const inspected = await inspectWorkflow(ctx.cwd);
3824
+ takeOver = typeof inspected.snapshot?.current_feature === "string"
3825
+ && !["completed", "cancelled", "failed"].includes(String(inspected.snapshot.status));
3826
+ }
3827
+ if (takeOver) {
3828
+ // runNextStep only uses members shared by event and command contexts.
3829
+ await runNextStep(ctx as DevflowCommandContext, "next");
3830
+ return { handled: true };
3831
+ }
3832
+ }
3549
3833
  // The in-memory prompt state is intentionally not authoritative. A fresh
3550
3834
  // session starts idle, so recover the approval arm from the durable Core
3551
3835
  // snapshot before handing an explicit approval phrase to the model. This
@@ -3581,10 +3865,8 @@ export default function devflowCore(pi: ExtensionAPI) {
3581
3865
  return { handled: true };
3582
3866
  }
3583
3867
  if (checkpoint && stepId === "wireframe-review" && typeof inspected.snapshot.current_feature === "string") {
3584
- const approval = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/wireframe_commit.rb", [
3585
- "--action", "approve", "--feature", inspected.snapshot.current_feature,
3586
- "--expected-revision", String(revision), "--all-screens", "--runtime-confirmed", "--write", "--json",
3587
- ]);
3868
+ const approval = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/wireframe_commit.rb",
3869
+ wireframeApprovalArgs(inspected.snapshot.current_feature, revision));
3588
3870
  if (!controlPlaneSucceeded(approval)) {
3589
3871
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "原型确认写入失败" });
3590
3872
  ctx.ui.notify(`原型确认未写入:${approval.stderr || approval.stdout}`, "error");
@@ -3600,7 +3882,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3600
3882
  ]);
3601
3883
  if (!result.ok || !controlPlaneSucceeded(result)) {
3602
3884
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
3603
- ctx.ui.notify(`未推进:${result.stderr || result.stdout}\n请补齐上述校验缺项后重新审批;重复批准不会绕过门禁。可请求重新执行当前步骤以修复产物。`, "error");
3885
+ ctx.ui.notify(`未推进:${describeControlFailure(result)}\n请补齐上述校验缺项后重新审批;重复批准不会绕过门禁。可以说「下一步」重新诊断,或选择重新执行这一步来修复产物。`, "error");
3604
3886
  return { handled: true };
3605
3887
  }
3606
3888
  const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
@@ -3837,6 +4119,20 @@ export default function devflowCore(pi: ExtensionAPI) {
3837
4119
  reason: "规范需求图谱、派生视图、机器 Evidence 与合同审计只能由 Core writer 写入;Skill 只能提交候选 delta 或 agent_claim。",
3838
4120
  };
3839
4121
  }
4122
+ // Feature artifacts written while no step is running are never adopted by
4123
+ // the workflow (zhaoshang, 2026-10-22). Refuse early in the interactive
4124
+ // main session; subagents (no UI), review-feedback edits and controlled
4125
+ // turns keep their existing rules.
4126
+ if (
4127
+ ctx.hasUI &&
4128
+ controlState.phase === "idle" &&
4129
+ !activeWorkflowTurn &&
4130
+ !toolName.startsWith("devflow_") &&
4131
+ ["write", "edit", "apply_patch", "notebook_edit"].includes(toolName) &&
4132
+ inputPaths(event.input, ctx.cwd).some(filePath => planningFeatureFromPath(filePath) !== null)
4133
+ ) {
4134
+ return { block: true, reason: OUTSIDE_STEP_WRITE_REASON };
4135
+ }
3840
4136
  if (
3841
4137
  !toolName.startsWith("devflow_") &&
3842
4138
  ["write", "edit", "apply_patch", "notebook_edit"].includes(toolName)
@@ -4293,6 +4589,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4293
4589
  label: "Devflow Explore",
4294
4590
  description: [
4295
4591
  "在已批准的 environment_profile 内启动结构化页面探索会话;探索结果不是验收结论,draft 仍必须经过编译、校验和用户批准。",
4592
+ "草稿到执行唯一经过:人工检查 review_items → regression-test-design 冻结 TC 并生成 tests.json → regression-dsl 使用 compile_dsl.py <草稿或 case-descriptions 路径>(无 --draft)编译、校验并提交 execution_plan 批准 → run-acceptance-tests 执行;草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若需激活合同,请通过 /devflow control → 启动当前可执行步骤进入。DSL 的 screenshot 只供用户在本地查看。",
4296
4593
  "凭据只按 profile.secret_env 注入 explore-host 子进程,永不写入 process.env、日志或工具输出。页面文本可能包含个人信息,本工具不做页面级个人信息脱敏。",
4297
4594
  ].join(" "),
4298
4595
  parameters: z.object({
@@ -4300,7 +4597,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4300
4597
  feature: z.string().min(1).optional(),
4301
4598
  environment_profile: z.string().min(1).optional(),
4302
4599
  url: z.string().min(1).optional(),
4303
- scope: z.string().optional(),
4600
+ scope: z.string().regex(exploreScopePattern, "scope must be a ref, role selector, or testid selector").optional(),
4304
4601
  full: z.boolean().optional(),
4305
4602
  table_text: z.boolean().optional(),
4306
4603
  ref: z.string().min(1).optional(),
@@ -4311,19 +4608,22 @@ export default function devflowCore(pi: ExtensionAPI) {
4311
4608
  kind: z.string().optional(),
4312
4609
  match: z.string().optional(),
4313
4610
  expected: z.unknown().optional(),
4611
+ normalize_whitespace: z.boolean().optional(),
4612
+ case_sensitive: z.boolean().optional(),
4314
4613
  name: z.string().optional(),
4315
4614
  tc_id: z.string().optional(),
4316
4615
  }),
4317
4616
  approval: args => args.action === "observe" || args.action === "stop" ? "read" : "write",
4318
4617
  async execute(_id, params, _signal, _onUpdate, ctx) {
4618
+ const currentSecrets = () => exploreHost.secrets;
4319
4619
  const fail = (message: string, code = "invalid_request") => ({
4320
- content: [{ type: "text" as const, text: message }],
4620
+ content: [{ type: "text" as const, text: String(redactExploreValue(message, currentSecrets())) }],
4321
4621
  details: { code },
4322
4622
  isError: true,
4323
4623
  });
4324
- const response = (result: unknown, secrets: Record<string, string> = {}) => ({
4325
- content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result, secrets), null, 2) }],
4326
- details: redactExploreValue(result, secrets),
4624
+ const response = (result: unknown) => ({
4625
+ content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result, currentSecrets()), null, 2) }],
4626
+ details: redactExploreValue(result, currentSecrets()),
4327
4627
  });
4328
4628
  try {
4329
4629
  if (activeContract && !["regression-test-design", "regression-dsl", "generate-mainflow-cases"].includes(activeContract.step)) {
@@ -4357,26 +4657,28 @@ export default function devflowCore(pi: ExtensionAPI) {
4357
4657
  const result = await exploreHost.request("start", {
4358
4658
  feature: params.feature, environment_profile: params.environment_profile, url: params.url,
4359
4659
  });
4360
- return response(result, childSecrets);
4660
+ return response(result);
4361
4661
  }
4362
4662
  if (!exploreHost.running) return fail("no active explore session", "session_missing");
4363
4663
  const requestParams: Record<string, unknown> = {};
4364
4664
  if (params.action === "observe") Object.assign(requestParams, { scope: params.scope, full: params.full, table_text: params.table_text });
4365
4665
  else if (params.action === "act") {
4366
- const operation = params.operation ?? params.action_name;
4367
- if (!params.ref || !operation) return fail("act requires ref and operation");
4368
- if (!["click", "fill", "check", "uncheck", "select", "press"].includes(operation)) return fail("act operation is invalid");
4369
- Object.assign(requestParams, { ref: params.ref, action: operation, value: params.value, value_from_secret: params.value_from_secret });
4666
+ if (!params.ref || !params.operation) return fail("act requires ref and operation");
4667
+ if (!["click", "fill", "check", "uncheck", "select", "press"].includes(params.operation)) return fail("act operation is invalid");
4668
+ Object.assign(requestParams, { ref: params.ref, action: params.operation, value: params.value, value_from_secret: params.value_from_secret });
4370
4669
  } else if (params.action === "assert") {
4371
4670
  if (!params.ref || !params.kind || !["text", "visible", "value", "each_text"].includes(params.kind)) return fail("assert requires ref and a supported kind");
4372
- Object.assign(requestParams, { ref: params.ref, kind: params.kind, match: params.match, expected: params.expected });
4671
+ Object.assign(requestParams, { ref: params.ref, kind: params.kind, match: params.match, expected: params.expected, normalize_whitespace: params.normalize_whitespace, case_sensitive: params.case_sensitive });
4373
4672
  } else if (params.action === "draft") {
4374
4673
  if (!params.name) return fail("draft requires name");
4375
4674
  Object.assign(requestParams, { name: params.name, tc_id: params.tc_id });
4376
4675
  }
4377
4676
  return response(await exploreHost.request(params.action, requestParams));
4378
4677
  } catch (error) {
4379
- return fail(error instanceof Error ? error.message : String(error), "explore_failed");
4678
+ if (params.action === "start") await exploreHost.stop("explore start failed");
4679
+ const message = error instanceof Error ? error.message : String(error);
4680
+ const code = error instanceof ExploreHostError && error.code ? error.code : "explore_failed";
4681
+ return fail(`[${code}] ${message}`, code);
4380
4682
  }
4381
4683
  },
4382
4684
  });
@@ -5218,273 +5520,288 @@ export default function devflowCore(pi: ExtensionAPI) {
5218
5520
  },
5219
5521
  });
5220
5522
 
5221
- pi.registerCommand("devflow", {
5222
- description: "Devflow runtime: inspect, initialize, plan, run one step, or explicitly advance the controlled delivery pipeline",
5223
- handler: async (args, ctx) => {
5224
- const trimmed = args.trim();
5225
- if (trimmed === "" || trimmed === "status") {
5226
- const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]);
5227
- ctx.ui.notify((result.stdout || result.stderr).slice(0, 6000), result.ok ? "info" : "error");
5523
+ const devflowCommand = async (args: string, ctx: DevflowCommandContext): Promise<void> => {
5524
+ const trimmed = args.trim();
5525
+ if (trimmed === "" || trimmed === "status") {
5526
+ await showWorkflowOverview(ctx);
5527
+ return;
5528
+ }
5529
+ if (trimmed === "status --json") {
5530
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]);
5531
+ ctx.ui.notify((result.stdout || result.stderr).slice(0, 6000), result.ok ? "info" : "error");
5532
+ return;
5533
+ }
5534
+ if (trimmed === "next" || trimmed === "下一步") {
5535
+ await runNextStep(ctx, "next");
5536
+ return;
5537
+ }
5538
+ if (trimmed === "fix") {
5539
+ await runNextStep(ctx, "fix");
5540
+ return;
5541
+ }
5542
+ if (trimmed === "control") {
5543
+ await openProjectControlPanel(ctx);
5544
+ return;
5545
+ }
5546
+ if (trimmed === "explain-state") {
5547
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5548
+ ctx.ui.notify(
5549
+ inspected.result.ok ? explainWorkflowSnapshot(inspected.snapshot) : `状态读取失败:${inspected.result.stderr || inspected.result.stdout}`,
5550
+ inspected.result.ok ? "info" : "error",
5551
+ );
5552
+ setControlState(ctx, initialControlState());
5553
+ return;
5554
+ }
5555
+ if (trimmed === "recover") {
5556
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5557
+ const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
5558
+ const revision = plan?.expected_revision;
5559
+ if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
5560
+ const status = typeof inspected.snapshot?.status === "string" ? inspected.snapshot.status : "unknown";
5561
+ const step = inspected.snapshot ? selectedWorkflowStep(inspected.snapshot) : null;
5562
+ const stepId = typeof step?.id === "string" ? step.id : "当前步骤";
5563
+ if (inspected.result.ok && status === "in_progress") {
5564
+ ctx.ui.notify(`控制面状态一致,无需恢复。当前步骤是 ${stepId};请审查当前产物后明确回复“批准执行”或提出修改意见。`, "info");
5565
+ } else if (inspected.result.ok && status === "waiting_checkpoint") {
5566
+ ctx.ui.notify("控制面状态一致,无需恢复。当前正在等待 checkpoint;请审查产物后明确回复“批准执行”或提出修改意见。", "info");
5567
+ } else {
5568
+ ctx.ui.notify("当前状态没有可安全自动执行的恢复计划;请运行 /devflow explain-state,并按其中的阻塞证据处理。", "error");
5569
+ }
5228
5570
  return;
5229
5571
  }
5230
- if (trimmed === "control") {
5231
- await openProjectControlPanel(ctx);
5572
+ const confirmed = await ctx.ui.confirm(
5573
+ "确认修复 Devflow 控制面",
5574
+ "将只根据已存在的文件同步 artifact registry;不会推进步骤、确认 checkpoint 或修改需求产物。继续吗?",
5575
+ );
5576
+ if (!confirmed) {
5577
+ ctx.ui.notify("恢复未确认;状态保持不变。", "info");
5232
5578
  return;
5233
5579
  }
5234
- if (trimmed === "explain-state") {
5235
- const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5580
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", [
5581
+ "--write",
5582
+ "--confirm",
5583
+ "--expected-revision",
5584
+ String(revision),
5585
+ "--json",
5586
+ ]);
5587
+ if (!result.ok) {
5588
+ ctx.ui.notify(`控制面恢复失败:${result.stderr || result.stdout}`, "error");
5589
+ return;
5590
+ }
5591
+ ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后明确回复“批准执行”或提出修改意见。", "info");
5592
+ return;
5593
+ }
5594
+ if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
5595
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5596
+ const snapshot = inspected.snapshot;
5597
+ const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
5598
+ const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
5599
+ const status = typeof snapshot?.status === "string" ? snapshot.status : undefined;
5600
+ if (!inspected.result.ok || revision === undefined || !feature) {
5601
+ ctx.ui.notify(`无法读取当前需求状态:${inspected.result.stderr || inspected.result.stdout}`, "error");
5602
+ return;
5603
+ }
5604
+ if (!status || ["completed", "cancelled", "failed"].includes(status)) {
5605
+ ctx.ui.notify(`当前没有可弃用的进行中需求(status=${status || "unknown"})。`, "info");
5606
+ return;
5607
+ }
5608
+ const reason = trimmed.split(/\s+/, 2)[1]?.trim() || "用户主动终止当前需求";
5609
+ const confirmed = await ctx.ui.confirm(
5610
+ "确认弃用当前需求",
5611
+ `需求:${feature}\n原因:${reason}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
5612
+ );
5613
+ if (!confirmed) {
5614
+ ctx.ui.notify("已取消弃用操作;状态保持不变。", "info");
5615
+ return;
5616
+ }
5617
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
5618
+ "--write", "--json", "--cancel", "--cancel-reason", reason, "--expected-revision", String(revision),
5619
+ ]);
5620
+ if (!result.ok) {
5621
+ ctx.ui.notify(`弃用当前需求失败:${result.stderr || result.stdout}`, "error");
5622
+ return;
5623
+ }
5624
+ activeContract = undefined;
5625
+ contractAgentCalls = 0;
5626
+ setControlState(ctx, initialControlState());
5627
+ ctx.ui.notify(`需求“${feature}”已弃用;现有工件已保留。现在可以创建新需求。`, "info");
5628
+ return;
5629
+ }
5630
+ if (trimmed === "project-init") {
5631
+ const initialization = await collectProjectInitialization(ctx);
5632
+ if (!initialization) return;
5633
+ pendingProjectInitialization = initialization;
5634
+ if (initialization.mode === "apply") {
5635
+ setControlState(ctx, executingState("执行已确认的项目初始化"));
5636
+ activeWorkflowTurn = true;
5637
+ await pi.sendUserMessage(projectInitializationDirective(initialization.summary));
5638
+ return;
5639
+ }
5640
+ setControlState(ctx, planningState("project-init"));
5641
+ await pi.sendUserMessage(
5642
+ [
5643
+ "<devflow-planner-directive>",
5644
+ "用户已确认项目初始化的输入。你现在是 Planner,只允许只读分析和派发 project-scout / requirements-auditor / contract-auditor。",
5645
+ "不得创建 .devflow/planning 文件、不得调用状态迁移、不得执行实现。",
5646
+ "先输出:已知事实、待验证点、最少侦察任务和必须由用户决定的问题。没有证据时写 unknown。",
5647
+ initialization.summary,
5648
+ "</devflow-planner-directive>",
5649
+ ].join("\n"),
5650
+ );
5651
+ return;
5652
+ }
5653
+ if (trimmed === "doctor") {
5654
+ const report = await runDoctor(ctx);
5655
+ ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), report.ok ? "info" : "error");
5656
+ return;
5657
+ }
5658
+ if (trimmed === "resume") {
5659
+ const report = await runDoctor(ctx);
5660
+ if (!report.ok) {
5661
+ ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
5662
+ return;
5663
+ }
5664
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5665
+ if (!inspected.result.ok || !inspected.snapshot) {
5236
5666
  ctx.ui.notify(
5237
- inspected.result.ok ? explainWorkflowSnapshot(inspected.snapshot) : `状态读取失败:${inspected.result.stderr || inspected.result.stdout}`,
5238
- inspected.result.ok ? "info" : "error",
5667
+ `Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`,
5668
+ "error",
5239
5669
  );
5240
- setControlState(ctx, initialControlState());
5241
5670
  return;
5242
5671
  }
5243
- if (trimmed === "recover") {
5244
- const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5245
- const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
5246
- const revision = plan?.expected_revision;
5247
- if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
5248
- const status = typeof inspected.snapshot?.status === "string" ? inspected.snapshot.status : "unknown";
5249
- const step = inspected.snapshot ? selectedWorkflowStep(inspected.snapshot) : null;
5250
- const stepId = typeof step?.id === "string" ? step.id : "当前步骤";
5251
- if (inspected.result.ok && status === "in_progress") {
5252
- ctx.ui.notify(`控制面状态一致,无需恢复。当前步骤是 ${stepId};请审查当前产物后明确回复“批准执行”或提出修改意见。`, "info");
5253
- } else if (inspected.result.ok && status === "waiting_checkpoint") {
5254
- ctx.ui.notify("控制面状态一致,无需恢复。当前正在等待 checkpoint;请审查产物后明确回复“批准执行”或提出修改意见。", "info");
5255
- } else {
5256
- ctx.ui.notify("当前状态没有可安全自动执行的恢复计划;请运行 /devflow explain-state,并按其中的阻塞证据处理。", "error");
5257
- }
5258
- return;
5259
- }
5260
- const confirmed = await ctx.ui.confirm(
5261
- "确认修复 Devflow 控制面",
5262
- "将只根据已存在的文件同步 artifact registry;不会推进步骤、确认 checkpoint 或修改需求产物。继续吗?",
5263
- );
5264
- if (!confirmed) {
5265
- ctx.ui.notify("恢复未确认;状态保持不变。", "info");
5266
- return;
5267
- }
5268
- const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", [
5269
- "--write",
5270
- "--confirm",
5271
- "--expected-revision",
5272
- String(revision),
5273
- "--json",
5274
- ]);
5275
- if (!result.ok) {
5276
- ctx.ui.notify(`控制面恢复失败:${result.stderr || result.stdout}`, "error");
5277
- return;
5278
- }
5279
- ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后明确回复“批准执行”或提出修改意见。", "info");
5672
+ // Re-arm a durable rollover left by an interrupted runtime. An
5673
+ // explicit resume is the user's authorization for a fresh session to
5674
+ // adopt the same project's integrity-checked prepared handoff.
5675
+ const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
5676
+ let rolloverState = parseJsonResult(rolloverResult);
5677
+ const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
5678
+ const currentSessionId = ctx.sessionManager.getSessionId();
5679
+ if (
5680
+ rolloverState &&
5681
+ typeof rolloverState.revision === "number" &&
5682
+ rollover?.status === "prepared" &&
5683
+ typeof rollover.id === "string"
5684
+ ) {
5685
+ pendingRollover = {
5686
+ snapshot: inspected.snapshot,
5687
+ preparedRevision: rolloverState.revision,
5688
+ rolloverId: rollover.id,
5689
+ };
5690
+ await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
5691
+ setControlState(ctx, waitingUserState("正在恢复受控会话交接"));
5692
+ ctx.ui.notify("Devflow 已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
5280
5693
  return;
5281
5694
  }
5282
- if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
5283
- const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5284
- const snapshot = inspected.snapshot;
5285
- const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
5286
- const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
5287
- const status = typeof snapshot?.status === "string" ? snapshot.status : undefined;
5288
- if (!inspected.result.ok || revision === undefined || !feature) {
5289
- ctx.ui.notify(`无法读取当前需求状态:${inspected.result.stderr || inspected.result.stdout}`, "error");
5290
- return;
5291
- }
5292
- if (!status || ["completed", "cancelled", "failed"].includes(status)) {
5293
- ctx.ui.notify(`当前没有可弃用的进行中需求(status=${status || "unknown"})。`, "info");
5294
- return;
5295
- }
5296
- const reason = trimmed.split(/\s+/, 2)[1]?.trim() || "用户主动终止当前需求";
5297
- const confirmed = await ctx.ui.confirm(
5298
- "确认弃用当前需求",
5299
- `需求:${feature}\n原因:${reason}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
5300
- );
5301
- if (!confirmed) {
5302
- ctx.ui.notify("已取消弃用操作;状态保持不变。", "info");
5303
- return;
5304
- }
5305
- const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
5306
- "--write", "--json", "--cancel", "--cancel-reason", reason, "--expected-revision", String(revision),
5307
- ]);
5308
- if (!result.ok) {
5309
- ctx.ui.notify(`弃用当前需求失败:${result.stderr || result.stdout}`, "error");
5310
- return;
5311
- }
5312
- activeContract = undefined;
5313
- contractAgentCalls = 0;
5314
- setControlState(ctx, initialControlState());
5315
- ctx.ui.notify(`需求“${feature}”已弃用;现有工件已保留。现在可以创建新需求。`, "info");
5695
+ await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
5696
+ return;
5697
+ }
5698
+ if (trimmed.startsWith("start ")) {
5699
+ const [feature, requestedPhase] = trimmed.slice(6).split("|", 2).map(value => value.trim());
5700
+ if (!feature) {
5701
+ ctx.ui.notify("Usage: /devflow start <feature>|<phase>", "error");
5316
5702
  return;
5317
5703
  }
5318
- if (trimmed === "project-init") {
5319
- const initialization = await collectProjectInitialization(ctx);
5320
- if (!initialization) return;
5321
- pendingProjectInitialization = initialization;
5322
- if (initialization.mode === "apply") {
5323
- setControlState(ctx, executingState("执行已确认的项目初始化"));
5324
- activeWorkflowTurn = true;
5325
- await pi.sendUserMessage(projectInitializationDirective(initialization.summary));
5326
- return;
5327
- }
5328
- setControlState(ctx, planningState("project-init"));
5329
- await pi.sendUserMessage(
5330
- [
5331
- "<devflow-planner-directive>",
5332
- "用户已确认项目初始化的输入。你现在是 Planner,只允许只读分析和派发 project-scout / requirements-auditor / contract-auditor。",
5333
- "不得创建 .devflow/planning 文件、不得调用状态迁移、不得执行实现。",
5334
- "先输出:已知事实、待验证点、最少侦察任务和必须由用户决定的问题。没有证据时写 unknown。",
5335
- initialization.summary,
5336
- "</devflow-planner-directive>",
5337
- ].join("\n"),
5704
+ const phase = requestedPhase || defaultPhaseId(feature);
5705
+ if (!validPhaseId(phase)) {
5706
+ ctx.ui.notify("Phase ID 必须是 1-64 位字母、数字、下划线或连字符,且不得包含路径分隔符。", "error");
5707
+ return;
5708
+ }
5709
+ const report = await runDoctor(ctx);
5710
+ if (!report.ok) {
5711
+ ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
5712
+ return;
5713
+ }
5714
+ const existing = await inspectWorkflow(ctx.cwd);
5715
+ const existingFeature = existing.snapshot?.current_feature;
5716
+ const previousWorkflowComplete = ["workflow_complete", "workflow_cancelled"].includes(existing.snapshot?.action as string);
5717
+ if (
5718
+ !previousWorkflowComplete &&
5719
+ typeof existingFeature === "string" &&
5720
+ existingFeature !== feature
5721
+ ) {
5722
+ ctx.ui.notify(
5723
+ `当前仍有未完成需求:${existingFeature}。请在对话中继续处理当前步骤,完成后再创建新需求:${feature}。`,
5724
+ "error",
5338
5725
  );
5339
5726
  return;
5340
5727
  }
5341
- if (trimmed === "doctor") {
5342
- const report = await runDoctor(ctx);
5343
- ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), report.ok ? "info" : "error");
5728
+ // This dry-run happens before init_workflow.rb, so a rejected feature
5729
+ // can never overwrite generated workflow files belonging to an active
5730
+ // owner or a legacy phase directory that has no trustworthy owner.
5731
+ const ownership = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/phase_ownership.rb", [
5732
+ "claim",
5733
+ "--feature",
5734
+ feature,
5735
+ "--phase",
5736
+ phase,
5737
+ "--flow",
5738
+ "feature-development",
5739
+ "--json",
5740
+ ]);
5741
+ if (!controlPlaneSucceeded(ownership)) {
5742
+ ctx.ui.notify((ownership.stderr || ownership.stdout).slice(0, 6000), "error");
5344
5743
  return;
5345
5744
  }
5346
- if (trimmed === "resume") {
5347
- const report = await runDoctor(ctx);
5348
- if (!report.ok) {
5349
- ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
5350
- return;
5351
- }
5352
- const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
5353
- if (!inspected.result.ok || !inspected.snapshot) {
5354
- ctx.ui.notify(
5355
- `Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`,
5356
- "error",
5357
- );
5358
- return;
5359
- }
5360
- // Re-arm a durable rollover left by an interrupted runtime. An
5361
- // explicit resume is the user's authorization for a fresh session to
5362
- // adopt the same project's integrity-checked prepared handoff.
5363
- const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
5364
- const rolloverState = parseJsonResult(rolloverResult);
5365
- const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
5366
- const currentSessionId = ctx.sessionManager.getSessionId();
5367
- if (
5368
- rolloverState &&
5369
- typeof rolloverState.revision === "number" &&
5370
- rollover?.status === "prepared" &&
5371
- typeof rollover.id === "string"
5372
- ) {
5373
- pendingRollover = {
5374
- snapshot: inspected.snapshot,
5375
- preparedRevision: rolloverState.revision,
5376
- rolloverId: rollover.id,
5377
- };
5378
- await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
5379
- setControlState(ctx, waitingUserState("正在恢复受控会话交接"));
5380
- ctx.ui.notify("Devflow 已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
5381
- return;
5382
- }
5383
- await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
5745
+ const workflow = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_workflow.rb", [
5746
+ "--write",
5747
+ "--json",
5748
+ ]);
5749
+ if (!controlPlaneSucceeded(workflow)) {
5750
+ ctx.ui.notify(workflow.stderr || workflow.stdout, "error");
5384
5751
  return;
5385
5752
  }
5386
- if (trimmed.startsWith("start ")) {
5387
- const [feature, requestedPhase] = trimmed.slice(6).split("|", 2).map(value => value.trim());
5388
- if (!feature) {
5389
- ctx.ui.notify("Usage: /devflow start <feature>|<phase>", "error");
5390
- return;
5391
- }
5392
- const phase = requestedPhase || defaultPhaseId(feature);
5393
- if (!validPhaseId(phase)) {
5394
- ctx.ui.notify("Phase ID 必须是 1-64 位字母、数字、下划线或连字符,且不得包含路径分隔符。", "error");
5395
- return;
5396
- }
5397
- const report = await runDoctor(ctx);
5398
- if (!report.ok) {
5399
- ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
5400
- return;
5401
- }
5402
- const existing = await inspectWorkflow(ctx.cwd);
5403
- const existingFeature = existing.snapshot?.current_feature;
5404
- const previousWorkflowComplete = ["workflow_complete", "workflow_cancelled"].includes(existing.snapshot?.action as string);
5405
- if (
5406
- !previousWorkflowComplete &&
5407
- typeof existingFeature === "string" &&
5408
- existingFeature !== feature
5409
- ) {
5410
- ctx.ui.notify(
5411
- `当前仍有未完成需求:${existingFeature}。请在对话中继续处理当前步骤,完成后再创建新需求:${feature}。`,
5412
- "error",
5413
- );
5414
- return;
5415
- }
5416
- // This dry-run happens before init_workflow.rb, so a rejected feature
5417
- // can never overwrite generated workflow files belonging to an active
5418
- // owner or a legacy phase directory that has no trustworthy owner.
5419
- const ownership = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/phase_ownership.rb", [
5420
- "claim",
5421
- "--feature",
5422
- feature,
5423
- "--phase",
5424
- phase,
5425
- "--flow",
5426
- "feature-development",
5427
- "--json",
5428
- ]);
5429
- if (!controlPlaneSucceeded(ownership)) {
5430
- ctx.ui.notify((ownership.stderr || ownership.stdout).slice(0, 6000), "error");
5431
- return;
5432
- }
5433
- const workflow = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_workflow.rb", [
5434
- "--write",
5435
- "--json",
5436
- ]);
5437
- if (!controlPlaneSucceeded(workflow)) {
5438
- ctx.ui.notify(workflow.stderr || workflow.stdout, "error");
5439
- return;
5440
- }
5441
- const state = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_state.rb", [
5442
- "--feature",
5443
- feature,
5444
- "--phase",
5445
- phase,
5446
- "--flow",
5447
- "feature-development",
5448
- "--write",
5449
- "--json",
5450
- ...(previousWorkflowComplete ? ["--force"] : []),
5451
- ]);
5452
- if (!controlPlaneSucceeded(state)) {
5453
- ctx.ui.notify((state.stderr || state.stdout).slice(0, 6000), "error");
5454
- return;
5455
- }
5456
- const artifacts = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
5457
- "--write",
5458
- "--json",
5459
- ]);
5460
- if (!controlPlaneSucceeded(artifacts)) {
5461
- ctx.ui.notify((artifacts.stderr || artifacts.stdout).slice(0, 6000), "error");
5462
- return;
5463
- }
5464
- const inspected = await inspectWorkflow(ctx.cwd);
5465
- if (!inspected.result.ok || !inspected.snapshot) {
5466
- ctx.ui.notify(
5467
- `Devflow initialized but inspector failed: ${inspected.result.stderr || inspected.result.stdout || "invalid JSON"}`,
5468
- "error",
5469
- );
5470
- return;
5471
- }
5472
- setControlState(ctx, planningState("requirement-draft"));
5473
- await pi.sendUserMessage(
5474
- [
5475
- "<devflow-requirement-draft-directive>",
5476
- `Devflow 已创建 Feature ${feature}。当前为安全的需求梳理阶段:允许澄清和只读证据收集,不得写入项目或迁移状态。`,
5477
- "请先基于功能名说明你需要的最少业务背景,并邀请用户直接补充目标、使用者、约束和验收结果。收到用户的下一段需求描述后,运行时会将其原文注入 requirement-triage 并启动该步骤。",
5478
- "不要让用户输入命令;也不要把普通需求文本当成批准或 checkpoint 确认。",
5479
- "</devflow-requirement-draft-directive>",
5480
- ].join("\n"),
5753
+ const state = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_state.rb", [
5754
+ "--feature",
5755
+ feature,
5756
+ "--phase",
5757
+ phase,
5758
+ "--flow",
5759
+ "feature-development",
5760
+ "--write",
5761
+ "--json",
5762
+ ...(previousWorkflowComplete ? ["--force"] : []),
5763
+ ]);
5764
+ if (!controlPlaneSucceeded(state)) {
5765
+ ctx.ui.notify((state.stderr || state.stdout).slice(0, 6000), "error");
5766
+ return;
5767
+ }
5768
+ const artifacts = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
5769
+ "--write",
5770
+ "--json",
5771
+ ]);
5772
+ if (!controlPlaneSucceeded(artifacts)) {
5773
+ ctx.ui.notify((artifacts.stderr || artifacts.stdout).slice(0, 6000), "error");
5774
+ return;
5775
+ }
5776
+ const inspected = await inspectWorkflow(ctx.cwd);
5777
+ if (!inspected.result.ok || !inspected.snapshot) {
5778
+ ctx.ui.notify(
5779
+ `Devflow initialized but inspector failed: ${inspected.result.stderr || inspected.result.stdout || "invalid JSON"}`,
5780
+ "error",
5481
5781
  );
5482
5782
  return;
5483
5783
  }
5484
- ctx.ui.notify(
5485
- "Usage: /devflow doctor | status | control | explain-state | recover | abandon [reason] | project-init | start <feature>|<phase> | resume",
5486
- "warning",
5784
+ setControlState(ctx, planningState("requirement-draft"));
5785
+ await pi.sendUserMessage(
5786
+ [
5787
+ "<devflow-requirement-draft-directive>",
5788
+ `Devflow 已创建 Feature ${feature}。当前为安全的需求梳理阶段:允许澄清和只读证据收集,不得写入项目或迁移状态。`,
5789
+ "请先基于功能名说明你需要的最少业务背景,并邀请用户直接补充目标、使用者、约束和验收结果。收到用户的下一段需求描述后,运行时会将其原文注入 requirement-triage 并启动该步骤。",
5790
+ "不要让用户输入命令;也不要把普通需求文本当成批准或 checkpoint 确认。",
5791
+ "</devflow-requirement-draft-directive>",
5792
+ ].join("\n"),
5487
5793
  );
5488
- },
5794
+ return;
5795
+ }
5796
+ ctx.ui.notify(
5797
+ "用法:/devflow(查看状态) | /devflow next(下一步) | /devflow fix(诊断修复) | /devflow cancel [原因] | /devflow control | /devflow start <需求名> | /devflow project-init | /devflow doctor\n兼容旧命令:status [--json] | explain-state | recover | abandon | resume",
5798
+ "warning",
5799
+ );
5800
+ };
5801
+ devflowCommandRef = devflowCommand;
5802
+
5803
+ pi.registerCommand("devflow", {
5804
+ description: "Devflow runtime: inspect, initialize, plan, run one step, or explicitly advance the controlled delivery pipeline",
5805
+ handler: devflowCommand,
5489
5806
  });
5490
5807
  }