@guanlin4924/devflow-pi 0.1.96 → 0.1.98

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 (38) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +697 -91
  4. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  21. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  22. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  23. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  24. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +1015 -0
  25. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  26. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  27. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +12 -2
  29. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  30. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  31. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  34. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +72 -0
  35. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  36. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
  37. package/src/constants.ts +1 -1
  38. 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,132 @@ 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
+ class ExploreHostError extends Error {
616
+ constructor(message: string, readonly code?: string) {
617
+ super(message);
618
+ this.name = "ExploreHostError";
619
+ }
620
+ }
621
+
622
+ /** Small JSON-lines client. The host owns Playwright; Core owns its lifetime. */
623
+ export class ExploreHostManager {
624
+ private child: any;
625
+ private nextId = 1;
626
+ private readerTask?: Promise<void>;
627
+ private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
628
+ private idleTimer?: ReturnType<typeof setTimeout>;
629
+ private totalTimer?: ReturnType<typeof setTimeout>;
630
+ private lastActivity = 0;
631
+ private exploreSecrets: Record<string, string> = {};
632
+
633
+ get running(): boolean { return Boolean(this.child); }
634
+ get secrets(): Record<string, string> { return { ...this.exploreSecrets }; }
635
+
636
+ async start(hostPath: string, cwd: string, environmentFile: string, secrets: Record<string, string>): Promise<void> {
637
+ if (this.child) throw new Error("an explore session is already active");
638
+ const inherited = process.env;
639
+ const env: Record<string, string> = {};
640
+ for (const name of ["PATH", "HOME", "TMPDIR", "LANG", "PLAYWRIGHT_BROWSERS_PATH"]) {
641
+ const value = inherited[name];
642
+ if (value !== undefined) env[name] = value;
643
+ }
644
+ env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE = environmentFile;
645
+ for (const [name, value] of Object.entries(secrets)) env[name] = value;
646
+ this.child = Bun.spawn(["node", hostPath], { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true });
647
+ this.exploreSecrets = { ...secrets };
648
+ void new Response(this.child.stderr).text();
649
+ this.lastActivity = Date.now();
650
+ this.readerTask = this.readResponses(this.child.stdout);
651
+ this.totalTimer = setTimeout(() => void this.stop("explore session exceeded the 60 minute limit"), EXPLORE_TOTAL_MS);
652
+ this.armIdleTimer();
653
+ }
654
+
655
+ async request(method: ExploreHostRequest["method"], params: Record<string, unknown> = {}): Promise<any> {
656
+ if (!this.child) throw new Error("no active explore session");
657
+ const id = this.nextId++;
658
+ const request = JSON.stringify({ id, method, params }) + "\n";
659
+ this.lastActivity = Date.now();
660
+ this.armIdleTimer();
661
+ const result = new Promise<unknown>((resolve, reject) => this.pending.set(id, { resolve, reject }));
662
+ try {
663
+ this.child.stdin.write(request);
664
+ } catch (error) {
665
+ this.pending.delete(id);
666
+ throw error;
667
+ }
668
+ return result;
669
+ }
670
+
671
+ async stop(reason = "explore session stopped"): Promise<void> {
672
+ const child = this.child;
673
+ if (!child) return;
674
+ this.child = undefined;
675
+ this.exploreSecrets = {};
676
+ if (this.idleTimer) clearTimeout(this.idleTimer);
677
+ if (this.totalTimer) clearTimeout(this.totalTimer);
678
+ for (const pending of this.pending.values()) pending.reject(new Error(reason));
679
+ this.pending.clear();
680
+ try { child.stdin?.end(); } catch { /* already closed */ }
681
+ const pid = typeof child.pid === "number" ? child.pid : undefined;
682
+ if (pid !== undefined) {
683
+ try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* exited */ } }
684
+ await new Promise(resolve => setTimeout(resolve, 100));
685
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } }
686
+ }
687
+ }
688
+
689
+ terminateNow(): void {
690
+ const pid = typeof this.child?.pid === "number" ? this.child.pid : undefined;
691
+ if (pid === undefined) return;
692
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } }
693
+ }
694
+
695
+ private armIdleTimer(): void {
696
+ if (this.idleTimer) clearTimeout(this.idleTimer);
697
+ const delay = Math.max(1, EXPLORE_IDLE_MS - (Date.now() - this.lastActivity));
698
+ this.idleTimer = setTimeout(() => void this.stop("explore session idle timeout"), delay);
699
+ }
700
+
701
+ private async readResponses(stream: ReadableStream<Uint8Array>): Promise<void> {
702
+ const reader = stream.getReader();
703
+ const decoder = new TextDecoder();
704
+ let buffer = "";
705
+ try {
706
+ for (;;) {
707
+ const chunk = await reader.read();
708
+ if (chunk.done) break;
709
+ buffer += decoder.decode(chunk.value, { stream: true });
710
+ const lines = buffer.split("\n");
711
+ buffer = lines.pop() ?? "";
712
+ for (const line of lines) {
713
+ if (!line.trim()) continue;
714
+ let response: any;
715
+ try { response = JSON.parse(line); } catch { continue; }
716
+ const waiter = this.pending.get(response?.id);
717
+ if (!waiter) continue;
718
+ this.pending.delete(response.id);
719
+ if (response.ok === true) waiter.resolve(response.result);
720
+ else waiter.reject(new ExploreHostError(
721
+ String(response?.error?.message ?? "explore-host request failed"),
722
+ typeof response?.error?.code === "string" ? response.error.code : undefined,
723
+ ));
724
+ }
725
+ }
726
+ } finally {
727
+ if (this.child) await this.stop("explore-host exited unexpectedly");
728
+ }
729
+ }
730
+ }
731
+
543
732
  type DoctorStatus = "pass" | "warn" | "fail";
544
733
 
545
734
  type DoctorCheck = {
@@ -597,6 +786,7 @@ const REQUIRED_EXTENSION_TOOLS = [
597
786
  "devflow_context_cursor",
598
787
  "devflow_design_rule",
599
788
  "devflow_mainflow",
789
+ "devflow_explore",
600
790
  "devflow_approval",
601
791
  ];
602
792
 
@@ -608,6 +798,7 @@ const REQUIRED_SKILLS = [
608
798
  "workflow-api-contract",
609
799
  "workflow-api-supplement",
610
800
  "workflow-acceptance-test-design",
801
+ "workflow-regression-test-design",
611
802
  "workflow-backend-tech-spec",
612
803
  "workflow-bug-fix",
613
804
  "workflow-change-plan",
@@ -673,8 +864,10 @@ const REQUIRED_SCRIPTS = [
673
864
  "workflow-orchestrator/scripts/acceptance_test_runner.py",
674
865
  "workflow-orchestrator/scripts/machine_evidence.rb",
675
866
  "workflow-orchestrator/scripts/review_preflight.py",
867
+ "workflow-orchestrator/scripts/startup_preflight.py",
676
868
  "workflow-orchestrator/scripts/cross_track_consistency_check.rb",
677
869
  "workflow-orchestrator/scripts/execution_contract.rb",
870
+ "workflow-orchestrator/scripts/explore_preflight.py",
678
871
  "workflow-code-index/scripts/code_index.py",
679
872
  "workflow-context-sync/scripts/context_sync_cursor.py",
680
873
  ];
@@ -947,6 +1140,13 @@ function renderResult(result: ProcessResult, details: Record<string, unknown> =
947
1140
  };
948
1141
  }
949
1142
 
1143
+ function redactCredentialOutput(result: ProcessResult, values: Record<string, string>): ProcessResult {
1144
+ const secrets = Object.values(values).filter(value => value.length > 0);
1145
+ if (secrets.length === 0) return result;
1146
+ const redact = (text: string) => secrets.reduce((current, secret) => current.split(secret).join("[REDACTED]"), text);
1147
+ return { ...result, stdout: redact(result.stdout), stderr: redact(result.stderr) };
1148
+ }
1149
+
950
1150
  function registeredNames(value: unknown): Set<string> {
951
1151
  if (Array.isArray(value)) {
952
1152
  return new Set(
@@ -995,6 +1195,16 @@ function parseJsonResult(result: ProcessResult): Record<string, unknown> | null
995
1195
  }
996
1196
  }
997
1197
 
1198
+ function parseJsonOutput(result: ProcessResult): Record<string, unknown> | null {
1199
+ if (!result.stdout) return null;
1200
+ try {
1201
+ const parsed = JSON.parse(result.stdout);
1202
+ return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
1203
+ } catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+
998
1208
  export function canonicalReviewJson(value: unknown): string {
999
1209
  if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
1000
1210
  if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
@@ -1115,7 +1325,7 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
1115
1325
  return "blocked";
1116
1326
  }
1117
1327
 
1118
- function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string): string {
1328
+ function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string, briefing?: string): string {
1119
1329
  const selectedStep = selectedWorkflowStep(snapshot);
1120
1330
  const reviewInstructions = selectedStep?.id === "code-review"
1121
1331
  ? [
@@ -1153,8 +1363,12 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
1153
1363
  : [];
1154
1364
  return [
1155
1365
  "<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>),激活成功后才能执行或写入。",
1366
+ briefing
1367
+ ? "Devflow Runtime 已完成开工预检(环境、交接、状态、合同、输入),以下开工简报中的事实已经核实;执行合同已激活,不要再次调用 devflow_state inspect 或 devflow_contract activate。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。"
1368
+ : "Devflow Runtime 已完成环境与状态检查。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
1369
+ ...(briefing
1370
+ ? ["<startup-briefing>", briefing, "</startup-briefing>"]
1371
+ : ["先调用 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
1372
  "只按 inspector 返回的当前 step 加载所需 Skill:step.skill 是精确 ID,必须读取 skill://<step.skill>,不得自行添加 workflow- 前缀或改写名称;本回合严禁读取或写入 xd://devflow_transition,严禁调用 devflow_transition,严禁执行 Skill 中的 complete-step/confirm-checkpoint,不得推进下一步骤;状态写回和会话切换由 Runtime 在用户确认后独占处理。",
1159
1373
  ...reviewInstructions,
1160
1374
  ...(parallelInstructions.length > 0 ? parallelInstructions : [
@@ -1584,6 +1798,28 @@ export function approvalReviewText(proposal: ProposalRecord): string {
1584
1798
 
1585
1799
  export default function devflowCore(pi: ExtensionAPI) {
1586
1800
  const { z } = pi.zod;
1801
+ const testCredentials = new TestCredentialStore();
1802
+ const exploreHost = new ExploreHostManager();
1803
+ process.once("exit", () => exploreHost.terminateNow());
1804
+
1805
+ const redactExploreValue = (value: unknown, secrets: Record<string, string>): unknown => {
1806
+ const values = Object.values(secrets).filter(Boolean);
1807
+ if (typeof value === "string") return values.reduce((text, secret) => text.split(secret).join("[REDACTED]"), value);
1808
+ if (Array.isArray(value)) return value.map(item => redactExploreValue(item, secrets));
1809
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactExploreValue(item, secrets)]));
1810
+ return value;
1811
+ };
1812
+ const exploreScopePattern = /^(?:e\d+|aria-ref=e\d+|role=[^:\s]+(?::.+)?|testid=.+)$/;
1813
+
1814
+ const exploreHostPath = (): string => {
1815
+ const candidates = [
1816
+ path.join(resolvePluginRoot(), "devflow-quality", "runtime", "playwright", "explore-host.mjs"),
1817
+ path.join(resolvePluginRoot(), "devflow-quality", "runtime", "playwright", "dist", "explore-host.mjs"),
1818
+ ];
1819
+ const found = candidates.find(candidate => existsSync(candidate));
1820
+ if (!found) throw new Error("explore-host.mjs is not installed; build the Devflow Playwright Runtime first");
1821
+ return found;
1822
+ };
1587
1823
  pi.registerTool({
1588
1824
  name: "devflow_project_context",
1589
1825
  label: "Devflow Project Context",
@@ -1690,10 +1926,13 @@ export default function devflowCore(pi: ExtensionAPI) {
1690
1926
  // during extension construction and never becomes model-controlled state.
1691
1927
  const cliSessionResumeRequested = process.env.DEVFLOW_CLI_SESSION_RESUME === "1";
1692
1928
  delete process.env.DEVFLOW_CLI_SESSION_RESUME;
1693
- let sessionId = randomUUID();
1929
+ let sessionId: string = randomUUID();
1694
1930
  let controlState: SessionControlState = initialControlState();
1695
1931
  let activeContract: ActiveExecutionContract | undefined;
1696
1932
  let contractAgentCalls = 0;
1933
+ let stepUserNotes: string[] = [];
1934
+ let lastAgentReport = "";
1935
+ let sessionBootstrapContext = "";
1697
1936
  const reviewBindingKey = randomBytes(32).toString("hex");
1698
1937
  const pendingReviewTasks = new Map<string, { feature: string; contractId: string }>();
1699
1938
  const completedReviewTasks = new Map<string, { feature: string; contractId: string; report: Record<string, unknown> }>();
@@ -1932,6 +2171,63 @@ export default function devflowCore(pi: ExtensionAPI) {
1932
2171
  return undefined;
1933
2172
  }
1934
2173
 
2174
+ async function activateExecutionContract(
2175
+ ctx: {
2176
+ cwd: string;
2177
+ ui: {
2178
+ confirm(title: string, body: string): Promise<boolean>;
2179
+ setStatus(key: string, text: string | undefined): void;
2180
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2181
+ };
2182
+ },
2183
+ feature: string,
2184
+ step: string,
2185
+ ): Promise<{ ok: true; contract: ActiveExecutionContract } | { ok: false; reason: string }> {
2186
+ const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", [
2187
+ "inspect", "--feature", feature, "--step", step, "--json",
2188
+ ]);
2189
+ const payload = parseJsonResult(inspected);
2190
+ const raw = payload?.contract;
2191
+ if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") {
2192
+ return { ok: false, reason: inspected.stderr || inspected.stdout || "Core returned no execution contract." };
2193
+ }
2194
+ const contract = contractFromRecord(raw as Record<string, unknown>);
2195
+ if (!contract) return { ok: false, reason: "Core returned an invalid execution contract." };
2196
+ const confirmed = await ctx.ui.confirm(
2197
+ "激活 Devflow 执行合同",
2198
+ [
2199
+ `合同:${contract.contractId}`,
2200
+ `Feature:${contract.feature}`,
2201
+ `步骤:${contract.step}`,
2202
+ `STATE revision:${contract.stateRevision ?? "未初始化"}`,
2203
+ `图谱 revision:${contract.graphRevision ?? 0}`,
2204
+ `可写路径:${contract.allowedPaths.join(", ")}`,
2205
+ `允许 Agent:${contract.allowedAgents.join(", ")}`,
2206
+ `固定检查器:${contract.checkerIds.join(", ") || "无"}`,
2207
+ `冻结哈希:${contract.frozenHash}`,
2208
+ "确认后低成本模型只能在以上范围执行;未知项必须阻塞升级。",
2209
+ ].join("\n"),
2210
+ );
2211
+ if (!confirmed) return { ok: false, reason: "User declined execution contract activation." };
2212
+ try {
2213
+ const createArgs = ["create", "--feature", feature, "--step", step, "--write", "--json"];
2214
+ if (contract.stateRevision !== undefined) createArgs.push("--expected-state-revision", String(contract.stateRevision));
2215
+ if (contract.graphRevision !== undefined) createArgs.push("--expected-graph-revision", String(contract.graphRevision));
2216
+ const written = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", createArgs);
2217
+ if (!controlPlaneSucceeded(written)) {
2218
+ return { ok: false, reason: written.stderr || written.stdout || "Execution contract activation failed." };
2219
+ }
2220
+ const writtenPayload = parseJsonResult(written);
2221
+ contract.contractPath = typeof writtenPayload?.path === "string" ? writtenPayload.path : ".devflow/planning/contracts/";
2222
+ activeContract = contract;
2223
+ contractAgentCalls = 0;
2224
+ setControlState(ctx, executingState(`合同执行:${contract.contractId}`));
2225
+ return { ok: true, contract };
2226
+ } catch (error) {
2227
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2228
+ }
2229
+ }
2230
+
1935
2231
  async function collectProjectInitialization(ctx: {
1936
2232
  cwd: string;
1937
2233
  ui: {
@@ -2055,12 +2351,13 @@ export default function devflowCore(pi: ExtensionAPI) {
2055
2351
  });
2056
2352
  }
2057
2353
 
2058
- async function runPython(cwd: string, relativeScript: string, args: string[]) {
2354
+ async function runPython(cwd: string, relativeScript: string, args: string[], extraEnv: Record<string, string> = {}) {
2059
2355
  assertRuntimeProjectBoundary(cwd);
2060
2356
  const kitRoot = resolveKitRoot(cwd);
2061
2357
  return runProcess(["python3", path.join(kitRoot, relativeScript), ...args], cwd, {
2062
2358
  DEVFLOW_KIT_ROOT: kitRoot,
2063
2359
  OMP_PROJECT_DIR: cwd,
2360
+ ...extraEnv,
2064
2361
  });
2065
2362
  }
2066
2363
 
@@ -2248,6 +2545,114 @@ export default function devflowCore(pi: ExtensionAPI) {
2248
2545
  return { ok: checks.every(check => check.status !== "fail"), checks, models };
2249
2546
  }
2250
2547
 
2548
+ async function runStartupPreflight(
2549
+ ctx: DoctorContext & {
2550
+ ui: {
2551
+ confirm(title: string, body: string): Promise<boolean>;
2552
+ notify(message: string, level: "info" | "error"): void;
2553
+ setStatus(key: string, text: string | undefined): void;
2554
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2555
+ };
2556
+ },
2557
+ snapshot: Record<string, unknown>,
2558
+ options: { doctorAlreadyPassed?: boolean; handoffPath?: string } = {},
2559
+ ): Promise<{ ok: true; briefing?: string } | { ok: false }> {
2560
+ if (options.doctorAlreadyPassed !== true) {
2561
+ const report = await runDoctor(ctx);
2562
+ if (!report.ok) {
2563
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检失败" });
2564
+ ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
2565
+ return { ok: false };
2566
+ }
2567
+ }
2568
+ if (workflowResumeDisposition(snapshot) !== "execute" || snapshot.action === "run_parallel_window") {
2569
+ return { ok: true };
2570
+ }
2571
+
2572
+ try {
2573
+ const feature = typeof snapshot.current_feature === "string" ? snapshot.current_feature : "";
2574
+ const step = String(selectedWorkflowStep(snapshot)?.id ?? "");
2575
+ if (!feature || !step) {
2576
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2577
+ ctx.ui.notify("开工预检未通过:当前 Feature 或步骤不可用。", "error");
2578
+ return { ok: false };
2579
+ }
2580
+ let contract = activeContract;
2581
+ if (!contract || contract.feature !== feature || contract.step !== step || !(await contractIsFresh(ctx.cwd, contract))) {
2582
+ const activated = await activateExecutionContract(ctx, feature, step);
2583
+ if (!activated.ok) {
2584
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2585
+ ctx.ui.notify(`开工预检:执行合同未激活。${activated.reason}`, "error");
2586
+ return { ok: false };
2587
+ }
2588
+ contract = activated.contract;
2589
+ }
2590
+ if (!contract.contractPath) {
2591
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2592
+ ctx.ui.notify("开工预检:执行合同未激活。合同冻结路径不可用。", "error");
2593
+ return { ok: false };
2594
+ }
2595
+ const contractAbsolute = path.resolve(ctx.cwd, contract.contractPath);
2596
+ const contractRelative = path.relative(ctx.cwd, contractAbsolute).split(path.sep).join("/");
2597
+ if (!contractRelative || contractRelative === ".." || contractRelative.startsWith("../")) {
2598
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检:执行合同未激活" });
2599
+ ctx.ui.notify("开工预检:执行合同未激活。合同冻结路径越出项目。", "error");
2600
+ return { ok: false };
2601
+ }
2602
+ const preflight = await runPython(ctx.cwd, "workflow-orchestrator/scripts/startup_preflight.py", [
2603
+ "--root", ctx.cwd,
2604
+ "--feature", feature,
2605
+ "--step", step,
2606
+ "--contract", contractRelative,
2607
+ ...(options.handoffPath ? ["--handoff", options.handoffPath] : []),
2608
+ ]);
2609
+ const payload = parseJsonOutput(preflight);
2610
+ if (!payload || payload.status !== "ready") {
2611
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2612
+ const issues = Array.isArray(payload?.issues) ? payload.issues : [];
2613
+ const details = issues.length > 0
2614
+ ? issues.map(issue => `- ${describeStartupIssue(issue && typeof issue === "object" ? issue as Record<string, unknown> : {})}`).join("\n")
2615
+ : "- 开工预检输出无法解析";
2616
+ ctx.ui.notify(`开工预检未通过:\n${details}`, "error");
2617
+ return { ok: false };
2618
+ }
2619
+ const facts = payload.facts && typeof payload.facts === "object" ? payload.facts as Record<string, unknown> : {};
2620
+ const handoff = payload.handoff && typeof payload.handoff === "object" ? payload.handoff as Record<string, unknown> : {};
2621
+ return {
2622
+ ok: true,
2623
+ briefing: renderStartupBriefing(facts, handoff, {
2624
+ revision: snapshot.revision,
2625
+ handoffProvided: Boolean(options.handoffPath),
2626
+ }),
2627
+ };
2628
+ } catch (error) {
2629
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2630
+ ctx.ui.notify(`开工预检未通过:${error instanceof Error ? error.message : String(error)}`, "error");
2631
+ return { ok: false };
2632
+ }
2633
+ }
2634
+
2635
+ async function startWorkflowAfterPreflight(
2636
+ ctx: DoctorContext & {
2637
+ ui: {
2638
+ confirm(title: string, body: string): Promise<boolean>;
2639
+ notify(message: string, level: "info" | "error"): void;
2640
+ setStatus(key: string, text: string | undefined): void;
2641
+ setWidget?(key: string, content: unknown, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2642
+ };
2643
+ },
2644
+ snapshot: Record<string, unknown>,
2645
+ source: "start" | "approval",
2646
+ options: { doctorAlreadyPassed?: boolean; handoffPath?: string; executingLabel?: string } = {},
2647
+ ): Promise<void> {
2648
+ const preflight = await runStartupPreflight(ctx, snapshot, options);
2649
+ if (!preflight.ok) return;
2650
+ setControlState(ctx, executingState(options.executingLabel));
2651
+ if (preflight.briefing) await triggerWorkflow(snapshot, source, undefined, preflight.briefing);
2652
+ else if (source === "start") await triggerWorkflow(snapshot, "start");
2653
+ else await triggerWorkflow(snapshot, "approval");
2654
+ }
2655
+
2251
2656
  async function inspectWorkflow(cwd: string): Promise<{ result: ProcessResult; snapshot: Record<string, unknown> | null }> {
2252
2657
  const result = await runRuby(cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]);
2253
2658
  return { result, snapshot: parseJsonResult(result) };
@@ -2294,26 +2699,33 @@ export default function devflowCore(pi: ExtensionAPI) {
2294
2699
  async function requestControlledRollover(ctx: { cwd: string; sessionManager: { getSessionId(): string } }, snapshot: Record<string, unknown>) {
2295
2700
  const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
2296
2701
  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");
2702
+ const carryPath = path.join(tmpdir(), `devflow-rollover-carry-${randomUUID()}.json`);
2703
+ try {
2704
+ await writeFile(carryPath, JSON.stringify({ user_notes: stepUserNotes, agent_report: lastAgentReport }), "utf8");
2705
+ const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
2706
+ "prepare", "--write", "--json", "--expected-revision", String(revision),
2707
+ "--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
2708
+ "--carry-file", carryPath,
2709
+ ]);
2710
+ const payload = parseJsonResult(prepared);
2711
+ if (!prepared.ok || !payload || typeof payload.revision !== "number") {
2712
+ throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
2713
+ }
2714
+ const rollover = payload.rollover as Record<string, unknown> | undefined;
2715
+ const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
2716
+ if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
2717
+ const synced = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
2718
+ "--write", "--no-preserve", "--json",
2719
+ ]);
2720
+ if (!controlPlaneSucceeded(synced)) {
2721
+ throw new Error(synced.stderr || synced.stdout || "artifact registry sync after rollover prepare failed");
2722
+ }
2723
+ // The runtime can observe the request immediately; arm the switch handler first.
2724
+ pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
2725
+ await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
2726
+ } finally {
2727
+ await unlink(carryPath).catch(() => undefined);
2313
2728
  }
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
2729
  }
2318
2730
 
2319
2731
  function parallelBranches(snapshot: Record<string, unknown>): Array<{ workflow_step: string; track: string }> {
@@ -2361,7 +2773,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2361
2773
  return { ...snapshot, parallel_run: payload.run };
2362
2774
  }
2363
2775
 
2364
- async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
2776
+ async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string, briefing?: string) {
2777
+ stepUserNotes = [];
2778
+ lastAgentReport = "";
2365
2779
  if (snapshot.action !== "run_parallel_window") {
2366
2780
  parallelRunActive = false;
2367
2781
  parallelContinuationQueued = false;
@@ -2372,26 +2786,35 @@ export default function devflowCore(pi: ExtensionAPI) {
2372
2786
  }
2373
2787
  const preparedSnapshot = await prepareParallelRun(projectRoot ?? process.cwd(), snapshot);
2374
2788
  activeWorkflowTurn = true;
2375
- await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
2789
+ await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft, briefing));
2376
2790
  }
2377
2791
 
2378
2792
  async function resumeInspectedWorkflow(
2379
2793
  ctx: {
2380
2794
  cwd: string;
2795
+ models: { resolve(spec: string): ModelLike | undefined };
2381
2796
  ui: {
2797
+ confirm(title: string, body: string): Promise<boolean>;
2382
2798
  notify(message: string, level: "info" | "error"): void;
2383
2799
  setStatus(key: string, text: string | undefined): void;
2384
2800
  setWidget?(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
2385
2801
  };
2386
2802
  },
2387
2803
  snapshot: Record<string, unknown>,
2804
+ options: { doctorAlreadyPassed: boolean },
2388
2805
  ): Promise<WorkflowResumeDisposition> {
2389
2806
  const disposition = workflowResumeDisposition(snapshot);
2390
2807
  const stepId = String(selectedWorkflowStep(snapshot)?.id ?? "当前步骤");
2391
2808
  if (disposition === "execute") {
2392
- setControlState(ctx, executingState(`恢复并执行 ${stepId}`));
2393
- ctx.ui.notify(`Devflow 已恢复到 ${stepId},当前步骤产物尚未完成,正在继续执行。`, "info");
2394
- await triggerWorkflow(snapshot, "start");
2809
+ setControlState(ctx, executingState("开工预检中"));
2810
+ ctx.ui.notify(`Devflow 已恢复到 ${stepId},当前步骤产物尚未完成,正在完成开工预检。`, "info");
2811
+ void startWorkflowAfterPreflight(ctx, snapshot, "start", {
2812
+ doctorAlreadyPassed: options.doctorAlreadyPassed,
2813
+ executingLabel: `恢复并执行 ${stepId}`,
2814
+ }).catch(error => {
2815
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
2816
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
2817
+ });
2395
2818
  return disposition;
2396
2819
  }
2397
2820
  if (disposition === "review") {
@@ -2488,6 +2911,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2488
2911
 
2489
2912
  type ProjectControlCommandContext = {
2490
2913
  cwd: string;
2914
+ models: DoctorContext["models"];
2491
2915
  sessionManager: { getSessionId(): string };
2492
2916
  ui: ProjectControlUi & {
2493
2917
  select(title: string, options: string[]): Promise<string | undefined>;
@@ -2668,6 +3092,45 @@ export default function devflowCore(pi: ExtensionAPI) {
2668
3092
  }
2669
3093
  }
2670
3094
 
3095
+ async function manageTestCredentials(ctx: ProjectControlCommandContext): Promise<void> {
3096
+ const names = declaredSecretEnvironmentNames(ctx.cwd);
3097
+ if (names.length === 0) {
3098
+ ctx.ui.notify("当前项目没有声明测试凭据(secret_env)。", "info");
3099
+ return;
3100
+ }
3101
+ for (;;) {
3102
+ const labels = names.map(name => `${name}(${testCredentials.has(name) || process.env[name] !== undefined ? "已设置" : "未设置"})`);
3103
+ const selected = await ctx.ui.select("测试凭据(输入内容会显示在屏幕上,不会持久化)", [
3104
+ ...labels,
3105
+ "清除全部",
3106
+ "返回",
3107
+ ]);
3108
+ if (!selected || selected === "返回") return;
3109
+ if (selected === "清除全部") {
3110
+ if (await ctx.ui.confirm("清除全部测试凭据", "只清除本次 devflow-pi 会话内存中的面板凭据;不会修改启动环境。继续吗?")) {
3111
+ testCredentials.clearAll();
3112
+ ctx.ui.notify("已清除本次会话的面板凭据。", "info");
3113
+ }
3114
+ continue;
3115
+ }
3116
+ const index = labels.indexOf(selected);
3117
+ const name = names[index];
3118
+ if (!name) continue;
3119
+ const action = await ctx.ui.select(`测试凭据:${name}`, ["设置/覆盖", "清除面板值", "返回"]);
3120
+ if (!action || action === "返回") continue;
3121
+ if (action === "清除面板值") {
3122
+ testCredentials.clear(name);
3123
+ ctx.ui.notify(`已清除 ${name} 的面板值;启动环境值仍可能存在。`, "info");
3124
+ continue;
3125
+ }
3126
+ const value = await ctx.ui.input(`输入测试凭据:${name}`, "输入值(会显示在屏幕上,不会持久化)");
3127
+ if (value === undefined) continue;
3128
+ if (!value) testCredentials.clear(name);
3129
+ else testCredentials.set(name, value);
3130
+ ctx.ui.notify(`${name} 已${value ? "设置" : "清除"}(值不会显示)。`, "info");
3131
+ }
3132
+ }
3133
+
2671
3134
  async function openProjectControlPanel(ctx: ProjectControlCommandContext): Promise<void> {
2672
3135
  for (;;) {
2673
3136
  // The control panel is a read-only projection. Each action below reads a
@@ -2683,6 +3146,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2683
3146
  if (snapshot.action === "await_user_confirmation") options.push("审查并批准推进…");
2684
3147
  if (hasSafeRecoveryPlan(snapshot)) options.push("执行安全恢复…");
2685
3148
  if (typeof snapshot.current_feature === "string" && !["completed", "cancelled", "failed"].includes(String(snapshot.status))) options.push("取消当前功能…");
3149
+ options.push("测试凭据…");
2686
3150
  const approvalCount = await pendingApprovalCount(ctx.cwd);
2687
3151
  const approvalOption = approvalCount === null ? null : `待审批(${approvalCount})…`;
2688
3152
  if (approvalOption) options.push(approvalOption);
@@ -2701,7 +3165,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2701
3165
  ctx.ui.notify(`启动前状态读取失败:${fresh.result.stderr || fresh.result.stdout}`, "error");
2702
3166
  return;
2703
3167
  }
2704
- await resumeInspectedWorkflow(ctx, fresh.snapshot);
3168
+ await resumeInspectedWorkflow(ctx, fresh.snapshot, { doctorAlreadyPassed: false });
2705
3169
  return;
2706
3170
  }
2707
3171
  if (selected === "审查并批准推进…") {
@@ -2716,6 +3180,10 @@ export default function devflowCore(pi: ExtensionAPI) {
2716
3180
  await cancelFeatureFromPanel(ctx);
2717
3181
  return;
2718
3182
  }
3183
+ if (selected === "测试凭据…") {
3184
+ await manageTestCredentials(ctx);
3185
+ continue;
3186
+ }
2719
3187
  if (approvalOption && selected === approvalOption) {
2720
3188
  await reviewPendingApprovalsFromPanel(ctx);
2721
3189
  continue;
@@ -2797,6 +3265,36 @@ export default function devflowCore(pi: ExtensionAPI) {
2797
3265
 
2798
3266
  pi.setLabel("Devflow Project Runtime");
2799
3267
 
3268
+ async function refreshSessionBootstrapContext(
3269
+ ctx: { cwd: string },
3270
+ source: "startup" | "rollover",
3271
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
3272
+ sessionBootstrapContext = "";
3273
+ try {
3274
+ const result = await runHook(ctx.cwd, "start.py", {
3275
+ session_id: sessionId,
3276
+ cwd: ctx.cwd,
3277
+ source,
3278
+ hook_event_name: "SessionStart",
3279
+ });
3280
+ if (!result.ok) return { ok: false, reason: result.stderr || result.stdout || "start.py did not complete successfully" };
3281
+ const bootstrap = parseJsonResult(result);
3282
+ const hookOutput = bootstrap?.hookSpecificOutput;
3283
+ const additionalContext = hookOutput && typeof hookOutput === "object"
3284
+ ? (hookOutput as Record<string, unknown>).additionalContext
3285
+ : undefined;
3286
+ if (typeof additionalContext === "string" && additionalContext) {
3287
+ sessionBootstrapContext = additionalContext.length > 8000
3288
+ ? `${additionalContext.slice(0, 7999)}…`
3289
+ : additionalContext;
3290
+ }
3291
+ return { ok: true };
3292
+ } catch (error) {
3293
+ sessionBootstrapContext = "";
3294
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
3295
+ }
3296
+ }
3297
+
2800
3298
  pi.on("session_start", async (_event, ctx) => {
2801
3299
  projectRoot = ctx.cwd;
2802
3300
  sessionId = randomUUID();
@@ -2806,14 +3304,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2806
3304
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
2807
3305
  return;
2808
3306
  }
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");
3307
+ const bootstrap = await refreshSessionBootstrapContext(ctx, "startup");
3308
+ if (!bootstrap.ok) {
3309
+ ctx.ui.notify(`Devflow bootstrap failed: ${bootstrap.reason}`, "error");
2817
3310
  return;
2818
3311
  }
2819
3312
  ctx.ui.setStatus("devflow", "Devflow ready");
@@ -2866,8 +3359,15 @@ export default function devflowCore(pi: ExtensionAPI) {
2866
3359
  ctx.ui.notify(`会话已恢复,但工作流校验失败,未启动 AI:${String(after.snapshot.reason ?? "state validation failed")}`, "error");
2867
3360
  return;
2868
3361
  }
2869
- setControlState(ctx, executingState("恢复已认领的干净会话"));
2870
- await triggerWorkflow(after.snapshot, "approval");
3362
+ setControlState(ctx, executingState("开工预检中"));
3363
+ void startWorkflowAfterPreflight(ctx, after.snapshot, "approval", {
3364
+ handoffPath: typeof rollover.handoff_path === "string" ? rollover.handoff_path : undefined,
3365
+ executingLabel: "恢复已认领的干净会话",
3366
+ }).catch(error => {
3367
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
3368
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
3369
+ });
3370
+ return;
2871
3371
  } else if (!consumed.ok || !synced || !controlPlaneSucceeded(synced)) {
2872
3372
  ctx.ui.notify(`已认领的会话交接无法恢复;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
2873
3373
  }
@@ -2883,7 +3383,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2883
3383
  ctx.ui.notify(`Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
2884
3384
  return;
2885
3385
  }
2886
- await resumeInspectedWorkflow(ctx, inspected.snapshot);
3386
+ await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
2887
3387
  }
2888
3388
  });
2889
3389
 
@@ -2925,6 +3425,8 @@ export default function devflowCore(pi: ExtensionAPI) {
2925
3425
  ctx.ui.notify(`Devflow 会话交接消费或 registry 同步失败;工作流保持冻结:${consumed.stderr || consumed.stdout || synced?.stderr || synced?.stdout}`, "error");
2926
3426
  return;
2927
3427
  }
3428
+ sessionId = targetSessionId;
3429
+ await refreshSessionBootstrapContext(ctx, "rollover");
2928
3430
  pendingRollover = undefined;
2929
3431
  activeContract = undefined;
2930
3432
  contractAgentCalls = 0;
@@ -2934,8 +3436,15 @@ export default function devflowCore(pi: ExtensionAPI) {
2934
3436
  ctx.ui.notify(`会话已切换,但工作流校验失败,未启动 AI:${String(next.snapshot?.reason ?? next.result.stderr ?? next.result.stdout ?? "state validation failed")}`, "error");
2935
3437
  return;
2936
3438
  }
2937
- setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
2938
- await triggerWorkflow(next.snapshot, "approval");
3439
+ const claimedRollover = claimedPayload.rollover as Record<string, unknown> | undefined;
3440
+ setControlState(ctx, executingState("开工预检中"));
3441
+ void startWorkflowAfterPreflight(ctx, next.snapshot, "approval", {
3442
+ handoffPath: typeof claimedRollover?.handoff_path === "string" ? claimedRollover.handoff_path : undefined,
3443
+ executingLabel: "在干净会话中执行下一工作流步骤",
3444
+ }).catch(error => {
3445
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "开工预检未通过" });
3446
+ ctx.ui.notify(`开工预检未通过:${String(error)}`, "error");
3447
+ });
2939
3448
  });
2940
3449
 
2941
3450
  pi.on("before_agent_start", async event => {
@@ -2943,11 +3452,25 @@ export default function devflowCore(pi: ExtensionAPI) {
2943
3452
  await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
2944
3453
  await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
2945
3454
  }
2946
- return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
3455
+ return {
3456
+ systemPrompt: [
3457
+ ...event.systemPrompt,
3458
+ ...(sessionBootstrapContext ? [sessionBootstrapContext] : []),
3459
+ DEVFLOW_CHINESE_LANGUAGE_POLICY,
3460
+ ],
3461
+ };
2947
3462
  });
2948
3463
 
2949
3464
  pi.on("agent_end", async (event, ctx) => {
2950
3465
  if (!activeWorkflowTurn || event.willContinue) return;
3466
+ const assistant = [...event.messages].reverse().find(message => (
3467
+ message && typeof message === "object" && (message as { role?: unknown }).role === "assistant"
3468
+ ));
3469
+ const content = assistant && typeof assistant === "object" ? (assistant as { content?: unknown }).content : undefined;
3470
+ lastAgentReport = Array.isArray(content)
3471
+ ? content.flatMap(part => part && typeof part === "object" && (part as { type?: unknown }).type === "text"
3472
+ && typeof (part as { text?: unknown }).text === "string" ? [(part as { text: string }).text] : []).join("")
3473
+ : "";
2951
3474
  activeWorkflowTurn = false;
2952
3475
  if (parallelRunActive) {
2953
3476
  parallelContinuationQueued = false;
@@ -2983,6 +3506,10 @@ export default function devflowCore(pi: ExtensionAPI) {
2983
3506
  pi.on("input", async (event, ctx) => {
2984
3507
  if (event.source !== "interactive") return;
2985
3508
  const controlAction = classifyNaturalControlAction(event.text);
3509
+ const stepNote = event.text.trim();
3510
+ if (controlState.intent === "workflow-execute" && !controlAction && stepNote && !stepNote.startsWith("/")) {
3511
+ stepUserNotes.push(stepNote);
3512
+ }
2986
3513
  if (controlState.intent === "requirement-draft" && controlState.phase === "planning") {
2987
3514
  const requirementDraft = event.text.trim();
2988
3515
  if (!requirementDraft) return { handled: true };
@@ -3471,6 +3998,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3471
3998
  });
3472
3999
 
3473
4000
  pi.on("session_stop", async (_event, ctx) => {
4001
+ await exploreHost.stop("Devflow session stopped");
3474
4002
  const result = await runHook(ctx.cwd, "context_sync_gate.py", {
3475
4003
  session_id: sessionId,
3476
4004
  cwd: ctx.cwd,
@@ -3775,6 +4303,103 @@ export default function devflowCore(pi: ExtensionAPI) {
3775
4303
  },
3776
4304
  });
3777
4305
 
4306
+ pi.registerTool({
4307
+ name: "devflow_explore",
4308
+ label: "Devflow Explore",
4309
+ description: [
4310
+ "在已批准的 environment_profile 内启动结构化页面探索会话;探索结果不是验收结论,draft 仍必须经过编译、校验和用户批准。",
4311
+ "草稿到执行唯一经过:人工检查 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 只供用户在本地查看。",
4312
+ "凭据只按 profile.secret_env 注入 explore-host 子进程,永不写入 process.env、日志或工具输出。页面文本可能包含个人信息,本工具不做页面级个人信息脱敏。",
4313
+ ].join(" "),
4314
+ parameters: z.object({
4315
+ action: z.enum(["start", "observe", "act", "assert", "draft", "stop"]),
4316
+ feature: z.string().min(1).optional(),
4317
+ environment_profile: z.string().min(1).optional(),
4318
+ url: z.string().min(1).optional(),
4319
+ scope: z.string().regex(exploreScopePattern, "scope must be a ref, role selector, or testid selector").optional(),
4320
+ full: z.boolean().optional(),
4321
+ table_text: z.boolean().optional(),
4322
+ ref: z.string().min(1).optional(),
4323
+ operation: z.enum(["click", "fill", "check", "uncheck", "select", "press"]).optional(),
4324
+ action_name: z.string().optional(),
4325
+ value: z.string().optional(),
4326
+ value_from_secret: z.string().optional(),
4327
+ kind: z.string().optional(),
4328
+ match: z.string().optional(),
4329
+ expected: z.unknown().optional(),
4330
+ name: z.string().optional(),
4331
+ tc_id: z.string().optional(),
4332
+ }),
4333
+ approval: args => args.action === "observe" || args.action === "stop" ? "read" : "write",
4334
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4335
+ const currentSecrets = () => exploreHost.secrets;
4336
+ const fail = (message: string, code = "invalid_request") => ({
4337
+ content: [{ type: "text" as const, text: String(redactExploreValue(message, currentSecrets())) }],
4338
+ details: { code },
4339
+ isError: true,
4340
+ });
4341
+ const response = (result: unknown) => ({
4342
+ content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result, currentSecrets()), null, 2) }],
4343
+ details: redactExploreValue(result, currentSecrets()),
4344
+ });
4345
+ try {
4346
+ if (activeContract && !["regression-test-design", "regression-dsl", "generate-mainflow-cases"].includes(activeContract.step)) {
4347
+ return fail("devflow_explore is only available while idle or during regression-test-design/DSL generation", "tool_not_allowed");
4348
+ }
4349
+ if (params.action === "stop") {
4350
+ await exploreHost.stop();
4351
+ return response({ ok: true, stopped: true });
4352
+ }
4353
+ if (params.action === "start") {
4354
+ if (!params.feature || !params.environment_profile || !params.url) return fail("start requires feature, environment_profile and url");
4355
+ assertRuntimeProjectBoundary(ctx.cwd);
4356
+ const preflight = await runPython(ctx.cwd, "workflow-orchestrator/scripts/explore_preflight.py", [
4357
+ "--root", ctx.cwd, "--feature", params.feature, "--environment-profile", params.environment_profile,
4358
+ ]);
4359
+ const checked = parseJsonResult(preflight);
4360
+ if (!preflight.ok || checked?.ok !== true) {
4361
+ return renderResult({ ...preflight, ok: false, exitCode: preflight.exitCode || 1 }, { code: checked?.reason ?? "environment_profile_not_approved" });
4362
+ }
4363
+ const secretNames = Array.isArray(checked.secret_names) ? checked.secret_names.filter((name): name is string => typeof name === "string") : [];
4364
+ const panelSecrets = testCredentials.forEnvironment(secretNames);
4365
+ const childSecrets: Record<string, string> = {};
4366
+ const missing: string[] = [];
4367
+ for (const name of secretNames) {
4368
+ const value = panelSecrets[name] ?? process.env[name];
4369
+ if (value === undefined || value === "") missing.push(name);
4370
+ else childSecrets[name] = value;
4371
+ }
4372
+ if (missing.length) return fail(`environment secret is missing: ${missing.join(", ")}`, "environment_secret_missing");
4373
+ await exploreHost.start(exploreHostPath(), ctx.cwd, String(checked.profile_path), childSecrets);
4374
+ const result = await exploreHost.request("start", {
4375
+ feature: params.feature, environment_profile: params.environment_profile, url: params.url,
4376
+ });
4377
+ return response(result);
4378
+ }
4379
+ if (!exploreHost.running) return fail("no active explore session", "session_missing");
4380
+ const requestParams: Record<string, unknown> = {};
4381
+ if (params.action === "observe") Object.assign(requestParams, { scope: params.scope, full: params.full, table_text: params.table_text });
4382
+ else if (params.action === "act") {
4383
+ if (!params.ref || !params.operation) return fail("act requires ref and operation");
4384
+ if (!["click", "fill", "check", "uncheck", "select", "press"].includes(params.operation)) return fail("act operation is invalid");
4385
+ Object.assign(requestParams, { ref: params.ref, action: params.operation, value: params.value, value_from_secret: params.value_from_secret });
4386
+ } else if (params.action === "assert") {
4387
+ if (!params.ref || !params.kind || !["text", "visible", "value", "each_text"].includes(params.kind)) return fail("assert requires ref and a supported kind");
4388
+ Object.assign(requestParams, { ref: params.ref, kind: params.kind, match: params.match, expected: params.expected });
4389
+ } else if (params.action === "draft") {
4390
+ if (!params.name) return fail("draft requires name");
4391
+ Object.assign(requestParams, { name: params.name, tc_id: params.tc_id });
4392
+ }
4393
+ return response(await exploreHost.request(params.action, requestParams));
4394
+ } catch (error) {
4395
+ if (params.action === "start") await exploreHost.stop("explore start failed");
4396
+ const message = error instanceof Error ? error.message : String(error);
4397
+ const code = error instanceof ExploreHostError && error.code ? error.code : "explore_failed";
4398
+ return fail(`[${code}] ${message}`, code);
4399
+ }
4400
+ },
4401
+ });
4402
+
3778
4403
  pi.registerTool({
3779
4404
  name: "devflow_run_acceptance_tests",
3780
4405
  label: "Devflow Run Acceptance Tests",
@@ -3795,10 +4420,17 @@ export default function devflowCore(pi: ExtensionAPI) {
3795
4420
  approval: args => args.action === "run" || (args.action === "gc_runs" && args.write === true) ? "write" : "read",
3796
4421
  async execute(_id, params, _signal, _onUpdate, ctx) {
3797
4422
  const args = acceptanceRunnerArgs(params);
3798
- const process = await runPython(ctx.cwd, "workflow-orchestrator/scripts/acceptance_test_runner.py", args);
4423
+ // Only the Core-owned acceptance execution receives panel credentials.
4424
+ // validate/validate_results and every other subprocess stay on the
4425
+ // inherited startup environment and never receive Map values.
4426
+ const credentialEnv = params.action === "run"
4427
+ ? testCredentials.forEnvironment(declaredSecretEnvironmentNames(ctx.cwd))
4428
+ : {};
4429
+ const process = await runPython(ctx.cwd, "workflow-orchestrator/scripts/acceptance_test_runner.py", args, credentialEnv);
4430
+ const safeProcess = redactCredentialOutput(process, credentialEnv);
3799
4431
  const payload = parseJsonResult(process);
3800
4432
  const failed = payload && typeof payload === "object" && (payload as { ok?: unknown }).ok === false;
3801
- return renderResult(failed ? { ...process, ok: false, exitCode: process.exitCode || 1 } : process, {
4433
+ return renderResult(failed ? { ...safeProcess, ok: false, exitCode: safeProcess.exitCode || 1 } : safeProcess, {
3802
4434
  action: params.action,
3803
4435
  feature: params.feature,
3804
4436
  });
@@ -4164,51 +4796,25 @@ export default function devflowCore(pi: ExtensionAPI) {
4164
4796
  };
4165
4797
  }
4166
4798
  if (!input.feature || !input.step) return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "feature and step are required; Core derives all control fields." });
4167
- const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", input.feature, "--step", input.step, "--json"]);
4168
- const payload = parseJsonResult(inspected);
4169
- const raw = payload?.contract;
4170
- if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
4171
- const contract = contractFromRecord(raw as Record<string, unknown>);
4172
- if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
4173
- if (input.action === "inspect") return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
4799
+ if (input.action === "inspect") {
4800
+ const inspected = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", input.feature, "--step", input.step, "--json"]);
4801
+ const payload = parseJsonResult(inspected);
4802
+ const raw = payload?.contract;
4803
+ if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
4804
+ const contract = contractFromRecord(raw as Record<string, unknown>);
4805
+ if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
4806
+ return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
4807
+ }
4174
4808
  if (!contractActivationPhaseAllowed(controlState.phase, activeWorkflowTurn)) {
4175
4809
  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." });
4176
4810
  }
4177
- const confirmed = await ctx.ui.confirm(
4178
- "激活 Devflow 执行合同",
4179
- [
4180
- `合同:${contract.contractId}`,
4181
- `Feature:${contract.feature}`,
4182
- `步骤:${contract.step}`,
4183
- `STATE revision:${contract.stateRevision ?? "未初始化"}`,
4184
- `图谱 revision:${contract.graphRevision ?? 0}`,
4185
- `可写路径:${contract.allowedPaths.join(", ")}`,
4186
- `允许 Agent:${contract.allowedAgents.join(", ")}`,
4187
- `固定检查器:${contract.checkerIds.join(", ") || "无"}`,
4188
- `冻结哈希:${contract.frozenHash}`,
4189
- "确认后低成本模型只能在以上范围执行;未知项必须阻塞升级。",
4190
- ].join("\n"),
4191
- );
4192
- if (!confirmed) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "User declined execution contract activation." });
4193
- try {
4194
- const createArgs = ["create", "--feature", input.feature, "--step", input.step, "--write", "--json"];
4195
- if (contract.stateRevision !== undefined) createArgs.push("--expected-state-revision", String(contract.stateRevision));
4196
- if (contract.graphRevision !== undefined) createArgs.push("--expected-graph-revision", String(contract.graphRevision));
4197
- const written = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/execution_contract.rb", createArgs);
4198
- if (!controlPlaneSucceeded(written)) return renderResult(written, { action: input.action });
4199
- const writtenPayload = parseJsonResult(written);
4200
- const destination = typeof writtenPayload?.path === "string" ? writtenPayload.path : ".devflow/planning/contracts/";
4201
- contract.contractPath = destination;
4202
- activeContract = contract;
4203
- contractAgentCalls = 0;
4204
- setControlState(ctx, executingState(`合同执行:${contract.contractId}`));
4205
- return {
4206
- content: [{ type: "text" as const, text: `Execution contract activated: ${destination}` }],
4207
- details: { contract, destination },
4208
- };
4209
- } catch (error) {
4210
- return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
4211
- }
4811
+ const activated = await activateExecutionContract(ctx, input.feature, input.step);
4812
+ if (!activated.ok) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: activated.reason });
4813
+ const destination = activated.contract.contractPath ?? ".devflow/planning/contracts/";
4814
+ return {
4815
+ content: [{ type: "text" as const, text: `Execution contract activated: ${destination}` }],
4816
+ details: { contract: activated.contract, destination },
4817
+ };
4212
4818
  },
4213
4819
  });
4214
4820
 
@@ -4256,7 +4862,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4256
4862
  parameters: z.object({
4257
4863
  feature: z.string().min(1),
4258
4864
  phase: z.string().min(1).optional(),
4259
- flow: z.enum(["feature-development", "acceptance-remediation"]).default("feature-development"),
4865
+ flow: z.enum(["feature-development", "acceptance-remediation", "regression-acceptance"]).default("feature-development"),
4260
4866
  force: z.boolean().default(false),
4261
4867
  }),
4262
4868
  approval: "write",
@@ -4793,7 +5399,7 @@ export default function devflowCore(pi: ExtensionAPI) {
4793
5399
  ctx.ui.notify("Devflow 已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
4794
5400
  return;
4795
5401
  }
4796
- await resumeInspectedWorkflow(ctx, inspected.snapshot);
5402
+ await resumeInspectedWorkflow(ctx, inspected.snapshot, { doctorAlreadyPassed: true });
4797
5403
  return;
4798
5404
  }
4799
5405
  if (trimmed.startsWith("start ")) {