@guanlin4924/devflow-pi 0.1.95 → 0.1.97

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 (49) hide show
  1. package/README.md +42 -2
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
  4. package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
  5. package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  24. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  25. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  26. package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
  27. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  28. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  29. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  30. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  31. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  34. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  35. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  36. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  37. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  38. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
  39. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  40. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  41. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
  42. package/src/cli.ts +10 -0
  43. package/src/constants.ts +1 -1
  44. package/src/doctor.ts +9 -0
  45. package/src/mcp/auth.ts +103 -0
  46. package/src/mcp/core.ts +102 -0
  47. package/src/mcp/server.ts +193 -0
  48. package/src/mcp/types.ts +5 -0
  49. package/src/startup-briefing.ts +109 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
- import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
@@ -15,6 +16,7 @@ import { loadProjectContext } from "../../../src/project-context.ts";
15
16
  import { runProjectInitialization } from "../../../src/project-initializer.ts";
16
17
  import { projectControlDatabasePath } from "../../../src/paths.ts";
17
18
  import { generateProjectDocuments } from "../../../src/project-documents.ts";
19
+ import { describeStartupIssue, renderStartupBriefing } from "../../../src/startup-briefing.ts";
18
20
  import { exportInitializationSnapshot, importLegacyInitialization, switchInitializationAuthority } from "../../../src/project-migration.ts";
19
21
  import { migrateLegacyProjectLayout } from "../../../src/project-layout.ts";
20
22
  import {
@@ -43,6 +45,67 @@ export type SessionControlState = {
43
45
  label: string;
44
46
  };
45
47
 
48
+ /**
49
+ * Credentials entered in the control panel are deliberately session-local.
50
+ * Keeping them in a Map (instead of process.env) is important: every Agent
51
+ * child process inherits the extension environment, while only the Core
52
+ * acceptance runner is allowed to receive these values explicitly.
53
+ */
54
+ export class TestCredentialStore {
55
+ private readonly values = new Map<string, string>();
56
+
57
+ set(name: string, value: string): void {
58
+ if (!name || !value) {
59
+ this.values.delete(name);
60
+ return;
61
+ }
62
+ this.values.set(name, value);
63
+ }
64
+
65
+ clear(name: string): void {
66
+ this.values.delete(name);
67
+ }
68
+
69
+ clearAll(): void {
70
+ this.values.clear();
71
+ }
72
+
73
+ has(name: string): boolean {
74
+ return this.values.has(name);
75
+ }
76
+
77
+ /** Return only values declared by the current project's profiles. */
78
+ forEnvironment(names: Iterable<string>): Record<string, string> {
79
+ const result: Record<string, string> = {};
80
+ for (const name of names) {
81
+ const value = this.values.get(name);
82
+ if (value !== undefined) result[name] = value;
83
+ }
84
+ return result;
85
+ }
86
+ }
87
+
88
+ export function declaredSecretEnvironmentNames(cwd: string): string[] {
89
+ const root = path.join(cwd, ".devflow", "planning", "test-environments");
90
+ if (!existsSync(root)) return [];
91
+ const names = new Set<string>();
92
+ for (const file of readdirSync(root)) {
93
+ if (!file.endsWith(".json")) continue;
94
+ try {
95
+ const profile = JSON.parse(readFileSync(path.join(root, file), "utf8")) as Record<string, unknown>;
96
+ const secretEnv = profile.secret_env;
97
+ if (!secretEnv || typeof secretEnv !== "object" || Array.isArray(secretEnv)) continue;
98
+ for (const value of Object.values(secretEnv as Record<string, unknown>)) {
99
+ if (typeof value === "string" && value) names.add(value);
100
+ }
101
+ } catch {
102
+ // Doctor owns profile validation; an invalid profile contributes no
103
+ // injectable names and therefore cannot widen the secret boundary.
104
+ }
105
+ }
106
+ return [...names].sort();
107
+ }
108
+
46
109
  type ProjectInitializationMode = "plan" | "apply";
47
110
 
48
111
  type ProjectInitializationRequest = {
@@ -540,6 +603,118 @@ type ProcessResult = {
540
603
  stderr: string;
541
604
  };
542
605
 
606
+ const EXPLORE_IDLE_MS = 10 * 60 * 1000;
607
+ const EXPLORE_TOTAL_MS = 60 * 60 * 1000;
608
+
609
+ export type ExploreHostRequest = {
610
+ id: number;
611
+ method: "start" | "observe" | "act" | "assert" | "draft" | "stop";
612
+ params?: Record<string, unknown>;
613
+ };
614
+
615
+ /** Small JSON-lines client. The host owns Playwright; Core owns its lifetime. */
616
+ export class ExploreHostManager {
617
+ private child: any;
618
+ private nextId = 1;
619
+ private readerTask?: Promise<void>;
620
+ private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
621
+ private idleTimer?: ReturnType<typeof setTimeout>;
622
+ private totalTimer?: ReturnType<typeof setTimeout>;
623
+ private lastActivity = 0;
624
+
625
+ get running(): boolean { return Boolean(this.child); }
626
+
627
+ async start(hostPath: string, cwd: string, environmentFile: string, secrets: Record<string, string>): Promise<void> {
628
+ if (this.child) throw new Error("an explore session is already active");
629
+ const inherited = process.env;
630
+ const env: Record<string, string> = {};
631
+ for (const name of ["PATH", "HOME", "TMPDIR", "LANG", "PLAYWRIGHT_BROWSERS_PATH"]) {
632
+ const value = inherited[name];
633
+ if (value !== undefined) env[name] = value;
634
+ }
635
+ env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE = environmentFile;
636
+ for (const [name, value] of Object.entries(secrets)) env[name] = value;
637
+ this.child = Bun.spawn(["node", hostPath], { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true });
638
+ void new Response(this.child.stderr).text();
639
+ this.lastActivity = Date.now();
640
+ this.readerTask = this.readResponses(this.child.stdout);
641
+ this.totalTimer = setTimeout(() => void this.stop("explore session exceeded the 60 minute limit"), EXPLORE_TOTAL_MS);
642
+ this.armIdleTimer();
643
+ }
644
+
645
+ async request(method: ExploreHostRequest["method"], params: Record<string, unknown> = {}): Promise<any> {
646
+ if (!this.child) throw new Error("no active explore session");
647
+ const id = this.nextId++;
648
+ const request = JSON.stringify({ id, method, params }) + "\n";
649
+ this.lastActivity = Date.now();
650
+ this.armIdleTimer();
651
+ const result = new Promise<unknown>((resolve, reject) => this.pending.set(id, { resolve, reject }));
652
+ try {
653
+ this.child.stdin.write(request);
654
+ } catch (error) {
655
+ this.pending.delete(id);
656
+ throw error;
657
+ }
658
+ return result;
659
+ }
660
+
661
+ async stop(reason = "explore session stopped"): Promise<void> {
662
+ const child = this.child;
663
+ if (!child) return;
664
+ this.child = undefined;
665
+ if (this.idleTimer) clearTimeout(this.idleTimer);
666
+ if (this.totalTimer) clearTimeout(this.totalTimer);
667
+ for (const pending of this.pending.values()) pending.reject(new Error(reason));
668
+ this.pending.clear();
669
+ try { child.stdin?.end(); } catch { /* already closed */ }
670
+ const pid = typeof child.pid === "number" ? child.pid : undefined;
671
+ if (pid !== undefined) {
672
+ try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* exited */ } }
673
+ await new Promise(resolve => setTimeout(resolve, 100));
674
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } }
675
+ }
676
+ }
677
+
678
+ terminateNow(): void {
679
+ const pid = typeof this.child?.pid === "number" ? this.child.pid : undefined;
680
+ if (pid === undefined) return;
681
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } }
682
+ }
683
+
684
+ private armIdleTimer(): void {
685
+ if (this.idleTimer) clearTimeout(this.idleTimer);
686
+ const delay = Math.max(1, EXPLORE_IDLE_MS - (Date.now() - this.lastActivity));
687
+ this.idleTimer = setTimeout(() => void this.stop("explore session idle timeout"), delay);
688
+ }
689
+
690
+ private async readResponses(stream: ReadableStream<Uint8Array>): Promise<void> {
691
+ const reader = stream.getReader();
692
+ const decoder = new TextDecoder();
693
+ let buffer = "";
694
+ try {
695
+ for (;;) {
696
+ const chunk = await reader.read();
697
+ if (chunk.done) break;
698
+ buffer += decoder.decode(chunk.value, { stream: true });
699
+ const lines = buffer.split("\n");
700
+ buffer = lines.pop() ?? "";
701
+ for (const line of lines) {
702
+ if (!line.trim()) continue;
703
+ let response: any;
704
+ try { response = JSON.parse(line); } catch { continue; }
705
+ const waiter = this.pending.get(response?.id);
706
+ if (!waiter) continue;
707
+ this.pending.delete(response.id);
708
+ if (response.ok === true) waiter.resolve(response.result);
709
+ else waiter.reject(new Error(String(response?.error?.message ?? "explore-host request failed")));
710
+ }
711
+ }
712
+ } finally {
713
+ if (this.child) await this.stop("explore-host exited unexpectedly");
714
+ }
715
+ }
716
+ }
717
+
543
718
  type DoctorStatus = "pass" | "warn" | "fail";
544
719
 
545
720
  type DoctorCheck = {
@@ -597,6 +772,7 @@ const REQUIRED_EXTENSION_TOOLS = [
597
772
  "devflow_context_cursor",
598
773
  "devflow_design_rule",
599
774
  "devflow_mainflow",
775
+ "devflow_explore",
600
776
  "devflow_approval",
601
777
  ];
602
778
 
@@ -608,6 +784,7 @@ const REQUIRED_SKILLS = [
608
784
  "workflow-api-contract",
609
785
  "workflow-api-supplement",
610
786
  "workflow-acceptance-test-design",
787
+ "workflow-regression-test-design",
611
788
  "workflow-backend-tech-spec",
612
789
  "workflow-bug-fix",
613
790
  "workflow-change-plan",
@@ -673,8 +850,10 @@ const REQUIRED_SCRIPTS = [
673
850
  "workflow-orchestrator/scripts/acceptance_test_runner.py",
674
851
  "workflow-orchestrator/scripts/machine_evidence.rb",
675
852
  "workflow-orchestrator/scripts/review_preflight.py",
853
+ "workflow-orchestrator/scripts/startup_preflight.py",
676
854
  "workflow-orchestrator/scripts/cross_track_consistency_check.rb",
677
855
  "workflow-orchestrator/scripts/execution_contract.rb",
856
+ "workflow-orchestrator/scripts/explore_preflight.py",
678
857
  "workflow-code-index/scripts/code_index.py",
679
858
  "workflow-context-sync/scripts/context_sync_cursor.py",
680
859
  ];
@@ -947,6 +1126,13 @@ function renderResult(result: ProcessResult, details: Record<string, unknown> =
947
1126
  };
948
1127
  }
949
1128
 
1129
+ function redactCredentialOutput(result: ProcessResult, values: Record<string, string>): ProcessResult {
1130
+ const secrets = Object.values(values).filter(value => value.length > 0);
1131
+ if (secrets.length === 0) return result;
1132
+ const redact = (text: string) => secrets.reduce((current, secret) => current.split(secret).join("[REDACTED]"), text);
1133
+ return { ...result, stdout: redact(result.stdout), stderr: redact(result.stderr) };
1134
+ }
1135
+
950
1136
  function registeredNames(value: unknown): Set<string> {
951
1137
  if (Array.isArray(value)) {
952
1138
  return new Set(
@@ -995,6 +1181,16 @@ function parseJsonResult(result: ProcessResult): Record<string, unknown> | null
995
1181
  }
996
1182
  }
997
1183
 
1184
+ function parseJsonOutput(result: ProcessResult): Record<string, unknown> | null {
1185
+ if (!result.stdout) return null;
1186
+ try {
1187
+ const parsed = JSON.parse(result.stdout);
1188
+ return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
1189
+ } catch {
1190
+ return null;
1191
+ }
1192
+ }
1193
+
998
1194
  export function canonicalReviewJson(value: unknown): string {
999
1195
  if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
1000
1196
  if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
@@ -1115,7 +1311,7 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
1115
1311
  return "blocked";
1116
1312
  }
1117
1313
 
1118
- function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string): string {
1314
+ function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string, briefing?: string): string {
1119
1315
  const selectedStep = selectedWorkflowStep(snapshot);
1120
1316
  const reviewInstructions = selectedStep?.id === "code-review"
1121
1317
  ? [
@@ -1153,8 +1349,12 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
1153
1349
  : [];
1154
1350
  return [
1155
1351
  "<devflow-runtime-directive>",
1156
- "Devflow 已通过启动预检。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
1157
- "先调用 devflow_state(action=\"inspect\") 重新确认 revision,再调用 devflow_contract(action=\"status\");若没有匹配当前 feature/current_step 的 fresh contract,立即调用 devflow_contract(action=\"activate\", feature=<current_feature>, step=<current_step>),激活成功后才能执行或写入。",
1352
+ briefing
1353
+ ? "Devflow Runtime 已完成开工预检(环境、交接、状态、合同、输入),以下开工简报中的事实已经核实;执行合同已激活,不要再次调用 devflow_state inspect 或 devflow_contract activate。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。"
1354
+ : "Devflow Runtime 已完成环境与状态检查。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
1355
+ ...(briefing
1356
+ ? ["<startup-briefing>", briefing, "</startup-briefing>"]
1357
+ : ["先调用 devflow_state(action=\"inspect\") 重新确认 revision,再调用 devflow_contract(action=\"status\");若没有匹配当前 feature/current_step 的 fresh contract,立即调用 devflow_contract(action=\"activate\", feature=<current_feature>, step=<current_step>),激活成功后才能执行或写入。"]),
1158
1358
  "只按 inspector 返回的当前 step 加载所需 Skill:step.skill 是精确 ID,必须读取 skill://<step.skill>,不得自行添加 workflow- 前缀或改写名称;本回合严禁读取或写入 xd://devflow_transition,严禁调用 devflow_transition,严禁执行 Skill 中的 complete-step/confirm-checkpoint,不得推进下一步骤;状态写回和会话切换由 Runtime 在用户确认后独占处理。",
1159
1359
  ...reviewInstructions,
1160
1360
  ...(parallelInstructions.length > 0 ? parallelInstructions : [
@@ -1584,6 +1784,27 @@ export function approvalReviewText(proposal: ProposalRecord): string {
1584
1784
 
1585
1785
  export default function devflowCore(pi: ExtensionAPI) {
1586
1786
  const { z } = pi.zod;
1787
+ const testCredentials = new TestCredentialStore();
1788
+ const exploreHost = new ExploreHostManager();
1789
+ process.once("exit", () => exploreHost.terminateNow());
1790
+
1791
+ const redactExploreValue = (value: unknown, secrets: Record<string, string>): unknown => {
1792
+ const values = Object.values(secrets).filter(Boolean);
1793
+ if (typeof value === "string") return values.reduce((text, secret) => text.split(secret).join("[REDACTED]"), value);
1794
+ if (Array.isArray(value)) return value.map(item => redactExploreValue(item, secrets));
1795
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactExploreValue(item, secrets)]));
1796
+ return value;
1797
+ };
1798
+
1799
+ const exploreHostPath = (): string => {
1800
+ const candidates = [
1801
+ path.join(resolvePluginRoot(), "devflow-quality", "runtime", "playwright", "explore-host.mjs"),
1802
+ path.join(resolvePluginRoot(), "devflow-quality", "runtime", "playwright", "dist", "explore-host.mjs"),
1803
+ ];
1804
+ const found = candidates.find(candidate => existsSync(candidate));
1805
+ if (!found) throw new Error("explore-host.mjs is not installed; build the Devflow Playwright Runtime first");
1806
+ return found;
1807
+ };
1587
1808
  pi.registerTool({
1588
1809
  name: "devflow_project_context",
1589
1810
  label: "Devflow Project Context",
@@ -1690,10 +1911,13 @@ export default function devflowCore(pi: ExtensionAPI) {
1690
1911
  // during extension construction and never becomes model-controlled state.
1691
1912
  const cliSessionResumeRequested = process.env.DEVFLOW_CLI_SESSION_RESUME === "1";
1692
1913
  delete process.env.DEVFLOW_CLI_SESSION_RESUME;
1693
- let sessionId = randomUUID();
1914
+ let sessionId: string = randomUUID();
1694
1915
  let controlState: SessionControlState = initialControlState();
1695
1916
  let activeContract: ActiveExecutionContract | undefined;
1696
1917
  let contractAgentCalls = 0;
1918
+ let stepUserNotes: string[] = [];
1919
+ let lastAgentReport = "";
1920
+ let sessionBootstrapContext = "";
1697
1921
  const reviewBindingKey = randomBytes(32).toString("hex");
1698
1922
  const pendingReviewTasks = new Map<string, { feature: string; contractId: string }>();
1699
1923
  const completedReviewTasks = new Map<string, { feature: string; contractId: string; report: Record<string, unknown> }>();
@@ -1932,6 +2156,63 @@ export default function devflowCore(pi: ExtensionAPI) {
1932
2156
  return undefined;
1933
2157
  }
1934
2158
 
2159
+ async function activateExecutionContract(
2160
+ ctx: {
2161
+ cwd: string;
2162
+ ui: {
2163
+ confirm(title: string, body: string): Promise<boolean>;
2164
+ setStatus(key: string, text: string | undefined): void;
2165
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2166
+ };
2167
+ },
2168
+ feature: string,
2169
+ step: string,
2170
+ ): Promise<{ ok: true; contract: ActiveExecutionContract } | { ok: false; reason: string }> {
2171
+ const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", [
2172
+ "inspect", "--feature", feature, "--step", step, "--json",
2173
+ ]);
2174
+ const payload = parseJsonResult(inspected);
2175
+ const raw = payload?.contract;
2176
+ if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") {
2177
+ return { ok: false, reason: inspected.stderr || inspected.stdout || "Core returned no execution contract." };
2178
+ }
2179
+ const contract = contractFromRecord(raw as Record<string, unknown>);
2180
+ if (!contract) return { ok: false, reason: "Core returned an invalid execution contract." };
2181
+ const confirmed = await ctx.ui.confirm(
2182
+ "激活 Devflow 执行合同",
2183
+ [
2184
+ `合同:${contract.contractId}`,
2185
+ `Feature:${contract.feature}`,
2186
+ `步骤:${contract.step}`,
2187
+ `STATE revision:${contract.stateRevision ?? "未初始化"}`,
2188
+ `图谱 revision:${contract.graphRevision ?? 0}`,
2189
+ `可写路径:${contract.allowedPaths.join(", ")}`,
2190
+ `允许 Agent:${contract.allowedAgents.join(", ")}`,
2191
+ `固定检查器:${contract.checkerIds.join(", ") || "无"}`,
2192
+ `冻结哈希:${contract.frozenHash}`,
2193
+ "确认后低成本模型只能在以上范围执行;未知项必须阻塞升级。",
2194
+ ].join("\n"),
2195
+ );
2196
+ if (!confirmed) return { ok: false, reason: "User declined execution contract activation." };
2197
+ try {
2198
+ const createArgs = ["create", "--feature", feature, "--step", step, "--write", "--json"];
2199
+ if (contract.stateRevision !== undefined) createArgs.push("--expected-state-revision", String(contract.stateRevision));
2200
+ if (contract.graphRevision !== undefined) createArgs.push("--expected-graph-revision", String(contract.graphRevision));
2201
+ const written = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", createArgs);
2202
+ if (!controlPlaneSucceeded(written)) {
2203
+ return { ok: false, reason: written.stderr || written.stdout || "Execution contract activation failed." };
2204
+ }
2205
+ const writtenPayload = parseJsonResult(written);
2206
+ contract.contractPath = typeof writtenPayload?.path === "string" ? writtenPayload.path : ".devflow/planning/contracts/";
2207
+ activeContract = contract;
2208
+ contractAgentCalls = 0;
2209
+ setControlState(ctx, executingState(`合同执行:${contract.contractId}`));
2210
+ return { ok: true, contract };
2211
+ } catch (error) {
2212
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2213
+ }
2214
+ }
2215
+
1935
2216
  async function collectProjectInitialization(ctx: {
1936
2217
  cwd: string;
1937
2218
  ui: {
@@ -2055,12 +2336,13 @@ export default function devflowCore(pi: ExtensionAPI) {
2055
2336
  });
2056
2337
  }
2057
2338
 
2058
- async function runPython(cwd: string, relativeScript: string, args: string[]) {
2339
+ async function runPython(cwd: string, relativeScript: string, args: string[], extraEnv: Record<string, string> = {}) {
2059
2340
  assertRuntimeProjectBoundary(cwd);
2060
2341
  const kitRoot = resolveKitRoot(cwd);
2061
2342
  return runProcess(["python3", path.join(kitRoot, relativeScript), ...args], cwd, {
2062
2343
  DEVFLOW_KIT_ROOT: kitRoot,
2063
2344
  OMP_PROJECT_DIR: cwd,
2345
+ ...extraEnv,
2064
2346
  });
2065
2347
  }
2066
2348
 
@@ -2248,6 +2530,114 @@ export default function devflowCore(pi: ExtensionAPI) {
2248
2530
  return { ok: checks.every(check => check.status !== "fail"), checks, models };
2249
2531
  }
2250
2532
 
2533
+ async function runStartupPreflight(
2534
+ ctx: DoctorContext & {
2535
+ ui: {
2536
+ confirm(title: string, body: string): Promise<boolean>;
2537
+ notify(message: string, level: "info" | "error"): void;
2538
+ setStatus(key: string, text: string | undefined): void;
2539
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2540
+ };
2541
+ },
2542
+ snapshot: Record<string, unknown>,
2543
+ options: { doctorAlreadyPassed?: boolean; handoffPath?: string } = {},
2544
+ ): Promise<{ ok: true; briefing?: string } | { ok: false }> {
2545
+ if (options.doctorAlreadyPassed !== true) {
2546
+ const report = await runDoctor(ctx);
2547
+ if (!report.ok) {
2548
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检失败" });
2549
+ ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
2550
+ return { ok: false };
2551
+ }
2552
+ }
2553
+ if (workflowResumeDisposition(snapshot) !== "execute" || snapshot.action === "run_parallel_window") {
2554
+ return { ok: true };
2555
+ }
2556
+
2557
+ try {
2558
+ const feature = typeof snapshot.current_feature === "string" ? snapshot.current_feature : "";
2559
+ const step = String(selectedWorkflowStep(snapshot)?.id ?? "");
2560
+ if (!feature || !step) {
2561
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2562
+ ctx.ui.notify("开工预检未通过:当前 Feature 或步骤不可用。", "error");
2563
+ return { ok: false };
2564
+ }
2565
+ let contract = activeContract;
2566
+ if (!contract || contract.feature !== feature || contract.step !== step || !(await contractIsFresh(ctx.cwd, contract))) {
2567
+ const activated = await activateExecutionContract(ctx, feature, step);
2568
+ if (!activated.ok) {
2569
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2570
+ ctx.ui.notify(`开工预检:执行合同未激活。${activated.reason}`, "error");
2571
+ return { ok: false };
2572
+ }
2573
+ contract = activated.contract;
2574
+ }
2575
+ if (!contract.contractPath) {
2576
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2577
+ ctx.ui.notify("开工预检:执行合同未激活。合同冻结路径不可用。", "error");
2578
+ return { ok: false };
2579
+ }
2580
+ const contractAbsolute = path.resolve(ctx.cwd, contract.contractPath);
2581
+ const contractRelative = path.relative(ctx.cwd, contractAbsolute).split(path.sep).join("/");
2582
+ if (!contractRelative || contractRelative === ".." || contractRelative.startsWith("../")) {
2583
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2584
+ ctx.ui.notify("开工预检:执行合同未激活。合同冻结路径越出项目。", "error");
2585
+ return { ok: false };
2586
+ }
2587
+ const preflight = await runPython(ctx.cwd, "workflow-orchestrator/scripts/startup_preflight.py", [
2588
+ "--root", ctx.cwd,
2589
+ "--feature", feature,
2590
+ "--step", step,
2591
+ "--contract", contractRelative,
2592
+ ...(options.handoffPath ? ["--handoff", options.handoffPath] : []),
2593
+ ]);
2594
+ const payload = parseJsonOutput(preflight);
2595
+ if (!payload || payload.status !== "ready") {
2596
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2597
+ const issues = Array.isArray(payload?.issues) ? payload.issues : [];
2598
+ const details = issues.length > 0
2599
+ ? issues.map(issue => `- ${describeStartupIssue(issue && typeof issue === "object" ? issue as Record<string, unknown> : {})}`).join("\n")
2600
+ : "- 开工预检输出无法解析";
2601
+ ctx.ui.notify(`开工预检未通过:\n${details}`, "error");
2602
+ return { ok: false };
2603
+ }
2604
+ const facts = payload.facts && typeof payload.facts === "object" ? payload.facts as Record<string, unknown> : {};
2605
+ const handoff = payload.handoff && typeof payload.handoff === "object" ? payload.handoff as Record<string, unknown> : {};
2606
+ return {
2607
+ ok: true,
2608
+ briefing: renderStartupBriefing(facts, handoff, {
2609
+ revision: snapshot.revision,
2610
+ handoffProvided: Boolean(options.handoffPath),
2611
+ }),
2612
+ };
2613
+ } catch (error) {
2614
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2615
+ ctx.ui.notify(`开工预检未通过:${error instanceof Error ? error.message : String(error)}`, "error");
2616
+ return { ok: false };
2617
+ }
2618
+ }
2619
+
2620
+ async function startWorkflowAfterPreflight(
2621
+ ctx: DoctorContext & {
2622
+ ui: {
2623
+ confirm(title: string, body: string): Promise<boolean>;
2624
+ notify(message: string, level: "info" | "error"): void;
2625
+ setStatus(key: string, text: string | undefined): void;
2626
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2627
+ };
2628
+ },
2629
+ snapshot: Record<string, unknown>,
2630
+ source: "start" | "approval",
2631
+ options: { doctorAlreadyPassed?: boolean; handoffPath?: string; executingLabel?: string } = {},
2632
+ ): Promise<void> {
2633
+ const preflight = await runStartupPreflight(ctx, snapshot, options);
2634
+ if (!preflight.ok) return;
2635
+ setControlState(ctx, executingState(options.executingLabel));
2636
+ if (preflight.briefing) await triggerWorkflow(snapshot, source, undefined, preflight.briefing);
2637
+ else if (source === "start") await triggerWorkflow(snapshot, "start");
2638
+ else await triggerWorkflow(snapshot, "approval");
2639
+ }
2640
+
2251
2641
  async function inspectWorkflow(cwd: string): Promise<{ result: ProcessResult; snapshot: Record<string, unknown> | null }> {
2252
2642
  const result = await runRuby(cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]);
2253
2643
  return { result, snapshot: parseJsonResult(result) };
@@ -2294,26 +2684,33 @@ export default function devflowCore(pi: ExtensionAPI) {
2294
2684
  async function requestControlledRollover(ctx: { cwd: string; sessionManager: { getSessionId(): string } }, snapshot: Record<string, unknown>) {
2295
2685
  const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
2296
2686
  if (revision === undefined) throw new Error("Cannot prepare session rollover without a STATE revision.");
2297
- const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
2298
- "prepare", "--write", "--json", "--expected-revision", String(revision),
2299
- "--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
2300
- ]);
2301
- const payload = parseJsonResult(prepared);
2302
- if (!prepared.ok || !payload || typeof payload.revision !== "number") {
2303
- throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
2304
- }
2305
- const rollover = payload.rollover as Record<string, unknown> | undefined;
2306
- const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
2307
- if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
2308
- const synced = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
2309
- "--write", "--no-preserve", "--json",
2310
- ]);
2311
- if (!controlPlaneSucceeded(synced)) {
2312
- throw new Error(synced.stderr || synced.stdout || "artifact registry sync after rollover prepare failed");
2687
+ const carryPath = path.join(tmpdir(), `devflow-rollover-carry-${randomUUID()}.json`);
2688
+ try {
2689
+ await writeFile(carryPath, JSON.stringify({ user_notes: stepUserNotes, agent_report: lastAgentReport }), "utf8");
2690
+ const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
2691
+ "prepare", "--write", "--json", "--expected-revision", String(revision),
2692
+ "--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
2693
+ "--carry-file", carryPath,
2694
+ ]);
2695
+ const payload = parseJsonResult(prepared);
2696
+ if (!prepared.ok || !payload || typeof payload.revision !== "number") {
2697
+ throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
2698
+ }
2699
+ const rollover = payload.rollover as Record<string, unknown> | undefined;
2700
+ const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
2701
+ if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
2702
+ const synced = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
2703
+ "--write", "--no-preserve", "--json",
2704
+ ]);
2705
+ if (!controlPlaneSucceeded(synced)) {
2706
+ throw new Error(synced.stderr || synced.stdout || "artifact registry sync after rollover prepare failed");
2707
+ }
2708
+ // The runtime can observe the request immediately; arm the switch handler first.
2709
+ pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
2710
+ await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
2711
+ } finally {
2712
+ await unlink(carryPath).catch(() => undefined);
2313
2713
  }
2314
- // The runtime can observe the request immediately; arm the switch handler first.
2315
- pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
2316
- await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
2317
2714
  }
2318
2715
 
2319
2716
  function parallelBranches(snapshot: Record<string, unknown>): Array<{ workflow_step: string; track: string }> {
@@ -2361,7 +2758,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2361
2758
  return { ...snapshot, parallel_run: payload.run };
2362
2759
  }
2363
2760
 
2364
- async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
2761
+ async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string, briefing?: string) {
2762
+ stepUserNotes = [];
2763
+ lastAgentReport = "";
2365
2764
  if (snapshot.action !== "run_parallel_window") {
2366
2765
  parallelRunActive = false;
2367
2766
  parallelContinuationQueued = false;
@@ -2372,26 +2771,35 @@ export default function devflowCore(pi: ExtensionAPI) {
2372
2771
  }
2373
2772
  const preparedSnapshot = await prepareParallelRun(projectRoot ?? process.cwd(), snapshot);
2374
2773
  activeWorkflowTurn = true;
2375
- await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
2774
+ await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft, briefing));
2376
2775
  }
2377
2776
 
2378
2777
  async function resumeInspectedWorkflow(
2379
2778
  ctx: {
2380
2779
  cwd: string;
2780
+ models: { resolve(spec: string): ModelLike | undefined };
2381
2781
  ui: {
2782
+ confirm(title: string, body: string): Promise<boolean>;
2382
2783
  notify(message: string, level: "info" | "error"): void;
2383
2784
  setStatus(key: string, text: string | undefined): void;
2384
2785
  setWidget?(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2385
2786
  };
2386
2787
  },
2387
2788
  snapshot: Record<string, unknown>,
2789
+ options: { doctorAlreadyPassed: boolean },
2388
2790
  ): Promise<WorkflowResumeDisposition> {
2389
2791
  const disposition = workflowResumeDisposition(snapshot);
2390
2792
  const stepId = String(selectedWorkflowStep(snapshot)?.id ?? "当前步骤");
2391
2793
  if (disposition === "execute") {
2392
- setControlState(ctx, executingState(`恢复并执行 ${stepId}`));
2393
- ctx.ui.notify(`Devflow 已恢复到 ${stepId},当前步骤产物尚未完成,正在继续执行。`, "info");
2394
- await triggerWorkflow(snapshot, "start");
2794
+ setControlState(ctx, executingState("开工预检中"));
2795
+ ctx.ui.notify(`Devflow 已恢复到 ${stepId},当前步骤产物尚未完成,正在完成开工预检。`, "info");
2796
+ void startWorkflowAfterPreflight(ctx, snapshot, "start", {
2797
+ doctorAlreadyPassed: options.doctorAlreadyPassed,
2798
+ executingLabel: `恢复并执行 ${stepId}`,
2799
+ }).catch(error => {
2800
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2801
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
2802
+ });
2395
2803
  return disposition;
2396
2804
  }
2397
2805
  if (disposition === "review") {
@@ -2488,6 +2896,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2488
2896
 
2489
2897
  type ProjectControlCommandContext = {
2490
2898
  cwd: string;
2899
+ models: DoctorContext["models"];
2491
2900
  sessionManager: { getSessionId(): string };
2492
2901
  ui: ProjectControlUi & {
2493
2902
  select(title: string, options: string[]): Promise<string | undefined>;
@@ -2668,6 +3077,45 @@ export default function devflowCore(pi: ExtensionAPI) {
2668
3077
  }
2669
3078
  }
2670
3079
 
3080
+ async function manageTestCredentials(ctx: ProjectControlCommandContext): Promise<void> {
3081
+ const names = declaredSecretEnvironmentNames(ctx.cwd);
3082
+ if (names.length === 0) {
3083
+ ctx.ui.notify("当前项目没有声明测试凭据(secret_env)。", "info");
3084
+ return;
3085
+ }
3086
+ for (;;) {
3087
+ const labels = names.map(name => `${name}(${testCredentials.has(name) || process.env[name] !== undefined ? "已设置" : "未设置"})`);
3088
+ const selected = await ctx.ui.select("测试凭据(输入内容会显示在屏幕上,不会持久化)", [
3089
+ ...labels,
3090
+ "清除全部",
3091
+ "返回",
3092
+ ]);
3093
+ if (!selected || selected === "返回") return;
3094
+ if (selected === "清除全部") {
3095
+ if (await ctx.ui.confirm("清除全部测试凭据", "只清除本次 devflow-pi 会话内存中的面板凭据;不会修改启动环境。继续吗?")) {
3096
+ testCredentials.clearAll();
3097
+ ctx.ui.notify("已清除本次会话的面板凭据。", "info");
3098
+ }
3099
+ continue;
3100
+ }
3101
+ const index = labels.indexOf(selected);
3102
+ const name = names[index];
3103
+ if (!name) continue;
3104
+ const action = await ctx.ui.select(`测试凭据:${name}`, ["设置/覆盖", "清除面板值", "返回"]);
3105
+ if (!action || action === "返回") continue;
3106
+ if (action === "清除面板值") {
3107
+ testCredentials.clear(name);
3108
+ ctx.ui.notify(`已清除 ${name} 的面板值;启动环境值仍可能存在。`, "info");
3109
+ continue;
3110
+ }
3111
+ const value = await ctx.ui.input(`输入测试凭据:${name}`, "输入值(会显示在屏幕上,不会持久化)");
3112
+ if (value === undefined) continue;
3113
+ if (!value) testCredentials.clear(name);
3114
+ else testCredentials.set(name, value);
3115
+ ctx.ui.notify(`${name} 已${value ? "设置" : "清除"}(值不会显示)。`, "info");
3116
+ }
3117
+ }
3118
+
2671
3119
  async function openProjectControlPanel(ctx: ProjectControlCommandContext): Promise<void> {
2672
3120
  for (;;) {
2673
3121
  // The control panel is a read-only projection. Each action below reads a
@@ -2683,6 +3131,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2683
3131
  if (snapshot.action === "await_user_confirmation") options.push("审查并批准推进…");
2684
3132
  if (hasSafeRecoveryPlan(snapshot)) options.push("执行安全恢复…");
2685
3133
  if (typeof snapshot.current_feature === "string" && !["completed", "cancelled", "failed"].includes(String(snapshot.status))) options.push("取消当前功能…");
3134
+ options.push("测试凭据…");
2686
3135
  const approvalCount = await pendingApprovalCount(ctx.cwd);
2687
3136
  const approvalOption = approvalCount === null ? null : `待审批(${approvalCount})…`;
2688
3137
  if (approvalOption) options.push(approvalOption);
@@ -2701,7 +3150,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2701
3150
  ctx.ui.notify(`启动前状态读取失败:${fresh.result.stderr || fresh.result.stdout}`, "error");
2702
3151
  return;
2703
3152
  }
2704
- await resumeInspectedWorkflow(ctx, fresh.snapshot);
3153
+ await resumeInspectedWorkflow(ctx, fresh.snapshot, { doctorAlreadyPassed: false });
2705
3154
  return;
2706
3155
  }
2707
3156
  if (selected === "审查并批准推进…") {
@@ -2716,6 +3165,10 @@ export default function devflowCore(pi: ExtensionAPI) {
2716
3165
  await cancelFeatureFromPanel(ctx);
2717
3166
  return;
2718
3167
  }
3168
+ if (selected === "测试凭据…") {
3169
+ await manageTestCredentials(ctx);
3170
+ continue;
3171
+ }
2719
3172
  if (approvalOption && selected === approvalOption) {
2720
3173
  await reviewPendingApprovalsFromPanel(ctx);
2721
3174
  continue;
@@ -2797,6 +3250,36 @@ export default function devflowCore(pi: ExtensionAPI) {
2797
3250
 
2798
3251
  pi.setLabel("Devflow Project Runtime");
2799
3252
 
3253
+ async function refreshSessionBootstrapContext(
3254
+ ctx: { cwd: string },
3255
+ source: "startup" | "rollover",
3256
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
3257
+ sessionBootstrapContext = "";
3258
+ try {
3259
+ const result = await runHook(ctx.cwd, "start.py", {
3260
+ session_id: sessionId,
3261
+ cwd: ctx.cwd,
3262
+ source,
3263
+ hook_event_name: "SessionStart",
3264
+ });
3265
+ if (!result.ok) return { ok: false, reason: result.stderr || result.stdout || "start.py did not complete successfully" };
3266
+ const bootstrap = parseJsonResult(result);
3267
+ const hookOutput = bootstrap?.hookSpecificOutput;
3268
+ const additionalContext = hookOutput && typeof hookOutput === "object"
3269
+ ? (hookOutput as Record<string, unknown>).additionalContext
3270
+ : undefined;
3271
+ if (typeof additionalContext === "string" && additionalContext) {
3272
+ sessionBootstrapContext = additionalContext.length > 8000
3273
+ ? `${additionalContext.slice(0, 7999)}…`
3274
+ : additionalContext;
3275
+ }
3276
+ return { ok: true };
3277
+ } catch (error) {
3278
+ sessionBootstrapContext = "";
3279
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
3280
+ }
3281
+ }
3282
+
2800
3283
  pi.on("session_start", async (_event, ctx) => {
2801
3284
  projectRoot = ctx.cwd;
2802
3285
  sessionId = randomUUID();
@@ -2806,14 +3289,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2806
3289
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
2807
3290
  return;
2808
3291
  }
2809
- const result = await runHook(ctx.cwd, "start.py", {
2810
- session_id: sessionId,
2811
- cwd: ctx.cwd,
2812
- source: "startup",
2813
- hook_event_name: "SessionStart",
2814
- });
2815
- if (!result.ok) {
2816
- ctx.ui.notify(`Devflow bootstrap failed: ${result.stderr || result.stdout}`, "error");
3292
+ const bootstrap = await refreshSessionBootstrapContext(ctx, "startup");
3293
+ if (!bootstrap.ok) {
3294
+ ctx.ui.notify(`Devflow bootstrap failed: ${bootstrap.reason}`, "error");
2817
3295
  return;
2818
3296
  }
2819
3297
  ctx.ui.setStatus("devflow", "Devflow ready");
@@ -2866,8 +3344,15 @@ export default function devflowCore(pi: ExtensionAPI) {
2866
3344
  ctx.ui.notify(`会话已恢复,但工作流校验失败,未启动 AI:${String(after.snapshot.reason ?? "state validation failed")}`, "error");
2867
3345
  return;
2868
3346
  }
2869
- setControlState(ctx, executingState("恢复已认领的干净会话"));
2870
- await triggerWorkflow(after.snapshot, "approval");
3347
+ setControlState(ctx, executingState("开工预检中"));
3348
+ void startWorkflowAfterPreflight(ctx, after.snapshot, "approval", {
3349
+ handoffPath: typeof rollover.handoff_path === "string" ? rollover.handoff_path : undefined,
3350
+ executingLabel: "恢复已认领的干净会话",
3351
+ }).catch(error => {
3352
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
3353
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
3354
+ });
3355
+ return;
2871
3356
  } else if (!consumed.ok || !synced || !controlPlaneSucceeded(synced)) {
2872
3357
  ctx.ui.notify(`已认领的会话交接无法恢复;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
2873
3358
  }
@@ -2883,7 +3368,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2883
3368
  ctx.ui.notify(`Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
2884
3369
  return;
2885
3370
  }
2886
- await resumeInspectedWorkflow(ctx, inspected.snapshot);
3371
+ await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
2887
3372
  }
2888
3373
  });
2889
3374
 
@@ -2925,6 +3410,8 @@ export default function devflowCore(pi: ExtensionAPI) {
2925
3410
  ctx.ui.notify(`Devflow 会话交接消费或 registry 同步失败;工作流保持冻结:${consumed.stderr || consumed.stdout || synced?.stderr || synced?.stdout}`, "error");
2926
3411
  return;
2927
3412
  }
3413
+ sessionId = targetSessionId;
3414
+ await refreshSessionBootstrapContext(ctx, "rollover");
2928
3415
  pendingRollover = undefined;
2929
3416
  activeContract = undefined;
2930
3417
  contractAgentCalls = 0;
@@ -2934,8 +3421,15 @@ export default function devflowCore(pi: ExtensionAPI) {
2934
3421
  ctx.ui.notify(`会话已切换,但工作流校验失败,未启动 AI:${String(next.snapshot?.reason ?? next.result.stderr ?? next.result.stdout ?? "state validation failed")}`, "error");
2935
3422
  return;
2936
3423
  }
2937
- setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
2938
- await triggerWorkflow(next.snapshot, "approval");
3424
+ const claimedRollover = claimedPayload.rollover as Record<string, unknown> | undefined;
3425
+ setControlState(ctx, executingState("开工预检中"));
3426
+ void startWorkflowAfterPreflight(ctx, next.snapshot, "approval", {
3427
+ handoffPath: typeof claimedRollover?.handoff_path === "string" ? claimedRollover.handoff_path : undefined,
3428
+ executingLabel: "在干净会话中执行下一工作流步骤",
3429
+ }).catch(error => {
3430
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
3431
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
3432
+ });
2939
3433
  });
2940
3434
 
2941
3435
  pi.on("before_agent_start", async event => {
@@ -2943,11 +3437,25 @@ export default function devflowCore(pi: ExtensionAPI) {
2943
3437
  await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
2944
3438
  await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
2945
3439
  }
2946
- return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
3440
+ return {
3441
+ systemPrompt: [
3442
+ ...event.systemPrompt,
3443
+ ...(sessionBootstrapContext ? [sessionBootstrapContext] : []),
3444
+ DEVFLOW_CHINESE_LANGUAGE_POLICY,
3445
+ ],
3446
+ };
2947
3447
  });
2948
3448
 
2949
3449
  pi.on("agent_end", async (event, ctx) => {
2950
3450
  if (!activeWorkflowTurn || event.willContinue) return;
3451
+ const assistant = [...event.messages].reverse().find(message => (
3452
+ message && typeof message === "object" && (message as { role?: unknown }).role === "assistant"
3453
+ ));
3454
+ const content = assistant && typeof assistant === "object" ? (assistant as { content?: unknown }).content : undefined;
3455
+ lastAgentReport = Array.isArray(content)
3456
+ ? content.flatMap(part => part && typeof part === "object" && (part as { type?: unknown }).type === "text"
3457
+ && typeof (part as { text?: unknown }).text === "string" ? [(part as { text: string }).text] : []).join("")
3458
+ : "";
2951
3459
  activeWorkflowTurn = false;
2952
3460
  if (parallelRunActive) {
2953
3461
  parallelContinuationQueued = false;
@@ -2983,6 +3491,10 @@ export default function devflowCore(pi: ExtensionAPI) {
2983
3491
  pi.on("input", async (event, ctx) => {
2984
3492
  if (event.source !== "interactive") return;
2985
3493
  const controlAction = classifyNaturalControlAction(event.text);
3494
+ const stepNote = event.text.trim();
3495
+ if (controlState.intent === "workflow-execute" && !controlAction && stepNote && !stepNote.startsWith("/")) {
3496
+ stepUserNotes.push(stepNote);
3497
+ }
2986
3498
  if (controlState.intent === "requirement-draft" && controlState.phase === "planning") {
2987
3499
  const requirementDraft = event.text.trim();
2988
3500
  if (!requirementDraft) return { handled: true };
@@ -3471,6 +3983,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3471
3983
  });
3472
3984
 
3473
3985
  pi.on("session_stop", async (_event, ctx) => {
3986
+ await exploreHost.stop("Devflow session stopped");
3474
3987
  const result = await runHook(ctx.cwd, "context_sync_gate.py", {
3475
3988
  session_id: sessionId,
3476
3989
  cwd: ctx.cwd,
@@ -3775,12 +4288,106 @@ export default function devflowCore(pi: ExtensionAPI) {
3775
4288
  },
3776
4289
  });
3777
4290
 
4291
+ pi.registerTool({
4292
+ name: "devflow_explore",
4293
+ label: "Devflow Explore",
4294
+ description: [
4295
+ "在已批准的 environment_profile 内启动结构化页面探索会话;探索结果不是验收结论,draft 仍必须经过编译、校验和用户批准。",
4296
+ "凭据只按 profile.secret_env 注入 explore-host 子进程,永不写入 process.env、日志或工具输出。页面文本可能包含个人信息,本工具不做页面级个人信息脱敏。",
4297
+ ].join(" "),
4298
+ parameters: z.object({
4299
+ action: z.enum(["start", "observe", "act", "assert", "draft", "stop"]),
4300
+ feature: z.string().min(1).optional(),
4301
+ environment_profile: z.string().min(1).optional(),
4302
+ url: z.string().min(1).optional(),
4303
+ scope: z.string().optional(),
4304
+ full: z.boolean().optional(),
4305
+ table_text: z.boolean().optional(),
4306
+ ref: z.string().min(1).optional(),
4307
+ operation: z.enum(["click", "fill", "check", "uncheck", "select", "press"]).optional(),
4308
+ action_name: z.string().optional(),
4309
+ value: z.string().optional(),
4310
+ value_from_secret: z.string().optional(),
4311
+ kind: z.string().optional(),
4312
+ match: z.string().optional(),
4313
+ expected: z.unknown().optional(),
4314
+ name: z.string().optional(),
4315
+ tc_id: z.string().optional(),
4316
+ }),
4317
+ approval: args => args.action === "observe" || args.action === "stop" ? "read" : "write",
4318
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4319
+ const fail = (message: string, code = "invalid_request") => ({
4320
+ content: [{ type: "text" as const, text: message }],
4321
+ details: { code },
4322
+ isError: true,
4323
+ });
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),
4327
+ });
4328
+ try {
4329
+ if (activeContract && !["regression-test-design", "regression-dsl", "generate-mainflow-cases"].includes(activeContract.step)) {
4330
+ return fail("devflow_explore is only available while idle or during regression-test-design/DSL generation", "tool_not_allowed");
4331
+ }
4332
+ if (params.action === "stop") {
4333
+ await exploreHost.stop();
4334
+ return response({ ok: true, stopped: true });
4335
+ }
4336
+ if (params.action === "start") {
4337
+ if (!params.feature || !params.environment_profile || !params.url) return fail("start requires feature, environment_profile and url");
4338
+ assertRuntimeProjectBoundary(ctx.cwd);
4339
+ const preflight = await runPython(ctx.cwd, "workflow-orchestrator/scripts/explore_preflight.py", [
4340
+ "--root", ctx.cwd, "--feature", params.feature, "--environment-profile", params.environment_profile,
4341
+ ]);
4342
+ const checked = parseJsonResult(preflight);
4343
+ if (!preflight.ok || checked?.ok !== true) {
4344
+ return renderResult({ ...preflight, ok: false, exitCode: preflight.exitCode || 1 }, { code: checked?.reason ?? "environment_profile_not_approved" });
4345
+ }
4346
+ const secretNames = Array.isArray(checked.secret_names) ? checked.secret_names.filter((name): name is string => typeof name === "string") : [];
4347
+ const panelSecrets = testCredentials.forEnvironment(secretNames);
4348
+ const childSecrets: Record<string, string> = {};
4349
+ const missing: string[] = [];
4350
+ for (const name of secretNames) {
4351
+ const value = panelSecrets[name] ?? process.env[name];
4352
+ if (value === undefined || value === "") missing.push(name);
4353
+ else childSecrets[name] = value;
4354
+ }
4355
+ if (missing.length) return fail(`environment secret is missing: ${missing.join(", ")}`, "environment_secret_missing");
4356
+ await exploreHost.start(exploreHostPath(), ctx.cwd, String(checked.profile_path), childSecrets);
4357
+ const result = await exploreHost.request("start", {
4358
+ feature: params.feature, environment_profile: params.environment_profile, url: params.url,
4359
+ });
4360
+ return response(result, childSecrets);
4361
+ }
4362
+ if (!exploreHost.running) return fail("no active explore session", "session_missing");
4363
+ const requestParams: Record<string, unknown> = {};
4364
+ if (params.action === "observe") Object.assign(requestParams, { scope: params.scope, full: params.full, table_text: params.table_text });
4365
+ 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 });
4370
+ } else if (params.action === "assert") {
4371
+ 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 });
4373
+ } else if (params.action === "draft") {
4374
+ if (!params.name) return fail("draft requires name");
4375
+ Object.assign(requestParams, { name: params.name, tc_id: params.tc_id });
4376
+ }
4377
+ return response(await exploreHost.request(params.action, requestParams));
4378
+ } catch (error) {
4379
+ return fail(error instanceof Error ? error.message : String(error), "explore_failed");
4380
+ }
4381
+ },
4382
+ });
4383
+
3778
4384
  pi.registerTool({
3779
4385
  name: "devflow_run_acceptance_tests",
3780
4386
  label: "Devflow Run Acceptance Tests",
3781
4387
  description: [
3782
4388
  "Validate or execute the adapters bound to the frozen TC acceptance suite: command, portable HTTP, and devflow_playwright (the Core-owned Playwright Runtime driven by a pinned DSL).",
3783
4389
  "Actions: validate (bindings and suite), validate_human (manual TCs), run (executes every automated TC and writes the canonical acceptance-results.json; Core is the only writer), validate_results (re-checks recorded results against current approvals and hashes), gc_runs (prunes .devflow/runs/<feature> execution directories per retention rules; dry-run by default, pass write=true to delete; optional retentionDays / unreferencedTtlDays).",
4390
+ "Failure analysis reads only the Core-produced result.json summary; trace files and screenshots are not readable by Agent tools and are for the user to inspect locally with Playwright in a terminal.",
3784
4391
  "devflow_playwright bindings require CURRENT human approvals created with devflow_approval and confirmed by the user in /devflow control: an execution_plan approval per TC (pins the DSL, TC and suite) and an environment_profile approval for the target profile; manual or downgraded TCs need a human_result / mode_override approval. You cannot confirm approvals yourself.",
3785
4392
  "Hard gate: a missing, superseded or expired approval, or a DSL/profile/suite/evidence hash that changed after the approval or the run, makes run and validate_results return ok=false and blocks run-acceptance-tests from completing; there is no soft override and results must never be written by hand.",
3786
4393
  ].join(" "),
@@ -3794,10 +4401,17 @@ export default function devflowCore(pi: ExtensionAPI) {
3794
4401
  approval: args => args.action === "run" || (args.action === "gc_runs" && args.write === true) ? "write" : "read",
3795
4402
  async execute(_id, params, _signal, _onUpdate, ctx) {
3796
4403
  const args = acceptanceRunnerArgs(params);
3797
- const process = await runPython(ctx.cwd, "workflow-orchestrator/scripts/acceptance_test_runner.py", args);
4404
+ // Only the Core-owned acceptance execution receives panel credentials.
4405
+ // validate/validate_results and every other subprocess stay on the
4406
+ // inherited startup environment and never receive Map values.
4407
+ const credentialEnv = params.action === "run"
4408
+ ? testCredentials.forEnvironment(declaredSecretEnvironmentNames(ctx.cwd))
4409
+ : {};
4410
+ const process = await runPython(ctx.cwd, "workflow-orchestrator/scripts/acceptance_test_runner.py", args, credentialEnv);
4411
+ const safeProcess = redactCredentialOutput(process, credentialEnv);
3798
4412
  const payload = parseJsonResult(process);
3799
4413
  const failed = payload && typeof payload === "object" && (payload as { ok?: unknown }).ok === false;
3800
- return renderResult(failed ? { ...process, ok: false, exitCode: process.exitCode || 1 } : process, {
4414
+ return renderResult(failed ? { ...safeProcess, ok: false, exitCode: safeProcess.exitCode || 1 } : safeProcess, {
3801
4415
  action: params.action,
3802
4416
  feature: params.feature,
3803
4417
  });
@@ -4163,51 +4777,25 @@ export default function devflowCore(pi: ExtensionAPI) {
4163
4777
  };
4164
4778
  }
4165
4779
  if (!input.feature || !input.step) return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "feature and step are required; Core derives all control fields." });
4166
- const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", input.feature, "--step", input.step, "--json"]);
4167
- const payload = parseJsonResult(inspected);
4168
- const raw = payload?.contract;
4169
- if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
4170
- const contract = contractFromRecord(raw as Record<string, unknown>);
4171
- if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
4172
- if (input.action === "inspect") return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
4780
+ if (input.action === "inspect") {
4781
+ const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", input.feature, "--step", input.step, "--json"]);
4782
+ const payload = parseJsonResult(inspected);
4783
+ const raw = payload?.contract;
4784
+ if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
4785
+ const contract = contractFromRecord(raw as Record<string, unknown>);
4786
+ if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
4787
+ return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
4788
+ }
4173
4789
  if (!contractActivationPhaseAllowed(controlState.phase, activeWorkflowTurn)) {
4174
4790
  return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "Contracts may only be activated from planning/review, or for the exact current step during a runtime-triggered workflow turn." });
4175
4791
  }
4176
- const confirmed = await ctx.ui.confirm(
4177
- "激活 Devflow 执行合同",
4178
- [
4179
- `合同:${contract.contractId}`,
4180
- `Feature:${contract.feature}`,
4181
- `步骤:${contract.step}`,
4182
- `STATE revision:${contract.stateRevision ?? "未初始化"}`,
4183
- `图谱 revision:${contract.graphRevision ?? 0}`,
4184
- `可写路径:${contract.allowedPaths.join(", ")}`,
4185
- `允许 Agent:${contract.allowedAgents.join(", ")}`,
4186
- `固定检查器:${contract.checkerIds.join(", ") || "无"}`,
4187
- `冻结哈希:${contract.frozenHash}`,
4188
- "确认后低成本模型只能在以上范围执行;未知项必须阻塞升级。",
4189
- ].join("\n"),
4190
- );
4191
- if (!confirmed) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "User declined execution contract activation." });
4192
- try {
4193
- const createArgs = ["create", "--feature", input.feature, "--step", input.step, "--write", "--json"];
4194
- if (contract.stateRevision !== undefined) createArgs.push("--expected-state-revision", String(contract.stateRevision));
4195
- if (contract.graphRevision !== undefined) createArgs.push("--expected-graph-revision", String(contract.graphRevision));
4196
- const written = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", createArgs);
4197
- if (!controlPlaneSucceeded(written)) return renderResult(written, { action: input.action });
4198
- const writtenPayload = parseJsonResult(written);
4199
- const destination = typeof writtenPayload?.path === "string" ? writtenPayload.path : ".devflow/planning/contracts/";
4200
- contract.contractPath = destination;
4201
- activeContract = contract;
4202
- contractAgentCalls = 0;
4203
- setControlState(ctx, executingState(`合同执行:${contract.contractId}`));
4204
- return {
4205
- content: [{ type: "text" as const, text: `Execution contract activated: ${destination}` }],
4206
- details: { contract, destination },
4207
- };
4208
- } catch (error) {
4209
- return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
4210
- }
4792
+ const activated = await activateExecutionContract(ctx, input.feature, input.step);
4793
+ if (!activated.ok) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: activated.reason });
4794
+ const destination = activated.contract.contractPath ?? ".devflow/planning/contracts/";
4795
+ return {
4796
+ content: [{ type: "text" as const, text: `Execution contract activated: ${destination}` }],
4797
+ details: { contract: activated.contract, destination },
4798
+ };
4211
4799
  },
4212
4800
  });
4213
4801
 
@@ -4255,7 +4843,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4255
4843
  parameters: z.object({
4256
4844
  feature: z.string().min(1),
4257
4845
  phase: z.string().min(1).optional(),
4258
- flow: z.enum(["feature-development", "acceptance-remediation"]).default("feature-development"),
4846
+ flow: z.enum(["feature-development", "acceptance-remediation", "regression-acceptance"]).default("feature-development"),
4259
4847
  force: z.boolean().default(false),
4260
4848
  }),
4261
4849
  approval: "write",
@@ -4792,7 +5380,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4792
5380
  ctx.ui.notify("Devflow 已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
4793
5381
  return;
4794
5382
  }
4795
- await resumeInspectedWorkflow(ctx, inspected.snapshot);
5383
+ await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
4796
5384
  return;
4797
5385
  }
4798
5386
  if (trimmed.startsWith("start ")) {