@guanlin4924/devflow-pi 0.1.24 → 0.1.26

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 (21) hide show
  1. package/README.md +8 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +252 -33
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +50 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +9 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +2 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +19 -3
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +24 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +25 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +4 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +136 -15
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +159 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +36 -0
  15. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -3
  16. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +4 -0
  17. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +13 -1
  19. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +2 -0
  20. package/src/constants.ts +1 -1
  21. package/src/runtime.ts +36 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
11
11
  安装与启动:
12
12
 
13
13
  ```bash
14
- npm install -g @guanlin4924/devflow-pi@0.1.23
14
+ npm install -g @guanlin4924/devflow-pi@0.1.26
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
@@ -115,6 +115,12 @@ bash .skills/install.sh --apply --force-config
115
115
 
116
116
  `/devflow start` 会先进入安全的需求梳理:可澄清并只读检查,不写入项目。用户随后直接补充需求,原文会被带入入口分诊并启动当前步骤。每个步骤结束后,系统展示产物和证据;用户明确回复“批准执行”才会推进并运行下一步,回复修改意见或“拒绝执行”则保持原步骤。普通需求描述绝不会自动确认 checkpoint。`/devflow explain-state` 发现控制面和制品 registry 漂移时会给出只读恢复计划;用户审查后可用 `/devflow recover` 确认仅修复可由文件系统证明的 registry 字段,它绝不推进步骤或自动通过 checkpoint。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。
117
117
 
118
+ ### 窗口操作指引与干净会话
119
+
120
+ Devflow 会在输入框上方显示与当前状态对应的“Devflow 操作指引”:空闲时给出初始化、创建功能和状态查看入口;需求梳理时提示直接补充需求;节点审查时提示回复“批准执行”或直接描述修改项;阻塞时只展示解释状态和安全恢复入口。
121
+
122
+ 正式节点经用户批准后,Devflow 会把已校验的状态和产物引用固化为内容校验的 handoff,再自动创建干净 Session 执行下一节点。交接期间工作流冻结;只有新 Session 成功认领并消费同一份 handoff 后才解除冻结。Devflow 不使用 OMP 的模型摘要 handoff,因此 `.planning/STATE.md` 仍是唯一流程事实源。
123
+
118
124
  ### 需求验收测试与 TDD 证据链
119
125
 
120
126
  新建功能在 `integration-verify` 后会先生成独立的 `TC-*` 验收测试设计,再生成变更清单:
@@ -130,6 +136,7 @@ bash .skills/install.sh --apply --force-config
130
136
  - `TC-*` 的步骤和 oracle 在代码出现前冻结;CP-3 在同一次确认中先校验候选绑定,再冻结 TC 并固定绑定哈希。需求或契约变化会使它 stale,不能继续以旧测试集宣称通过。
131
137
  - `tdd_required` 任务必须先由 Core 记录准备快照,再记录 RED(命中预期失败)、同一目标的 GREEN 和回归;缺失或环境失败不算完成。
132
138
  - API、跨数据域、边界、迁移、工作负载与架构风险会派生相应的 `api`、`permission`、`boundary`、`migration`、`performance`、`resilience` TC 义务。
139
+ - 每个 TC 还要声明证明策略:`replay` 用组件/页面/API/集成的稳定回放;`causal` 用已定位的首个偏差加 HAR、控制台、DOM 快照、截图或日志等受控文件证明,Core 会记录文件哈希;`observation` 用于尚不可复现、也未能证明根因的问题,只能保持阻塞并带观测计划。后两者都不能被“AI 已复现”或 Markdown 自述放行。
133
140
  - 代码完成后,`workflow-run-acceptance-tests` 只能运行绑定到冻结 TC、且已在初始化 `.planning/toolchain.md` 发现的测试运行器命令。任何失败、未执行、人工待执行或环境阻塞用例都会阻止 CP-4。
134
141
  - 验收不是代码完成后的临时手工检查。它是需求基线在编码前派生并冻结的完整测试集,覆盖主流程、异常、边界、权限、迁移、兼容、韧性和工作负载义务;代码只能补充执行适配信息,不能改变测试意图。
135
142
  - 验收失败不会创建另一套人工台账,而是进入 `acceptance-remediation`:诊断 → 修复 → code-index 刷新 → 原冻结 `TC-*` 重跑。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
- import { mkdir, writeFile } from "node:fs/promises";
3
+ import { mkdir, rename, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
@@ -29,6 +29,7 @@ type ActiveExecutionContract = {
29
29
  step: string;
30
30
  stateRevision?: number;
31
31
  graphRevision?: number;
32
+ workflowFingerprint?: string;
32
33
  allowedPaths: string[];
33
34
  allowedAgents: string[];
34
35
  allowedTools: string[];
@@ -41,6 +42,30 @@ type ActiveExecutionContract = {
41
42
  onUnknown: "block_and_escalate";
42
43
  };
43
44
 
45
+ function contractFromRecord(record: Record<string, unknown>): ActiveExecutionContract | null {
46
+ const strings = (key: string): string[] =>
47
+ Array.isArray(record[key]) ? record[key].filter((item): item is string => typeof item === "string") : [];
48
+ const contract: ActiveExecutionContract = {
49
+ contractId: String(record.contract_id ?? ""),
50
+ feature: String(record.feature ?? ""),
51
+ step: String(record.step ?? ""),
52
+ stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
53
+ graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
54
+ workflowFingerprint: typeof record.workflow_fingerprint === "string" ? record.workflow_fingerprint : undefined,
55
+ allowedPaths: strings("allowed_paths"),
56
+ allowedAgents: strings("allowed_agents"),
57
+ allowedTools: strings("allowed_tools"),
58
+ checkerIds: strings("checker_ids"),
59
+ toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
60
+ frozenHash: String(record.frozen_hash ?? ""),
61
+ acceptance: strings("acceptance"),
62
+ allowedGraphDelta: strings("allowed_graph_delta"),
63
+ onUnknown: "block_and_escalate",
64
+ };
65
+ if (!contract.contractId || !contract.frozenHash) return null;
66
+ return contract;
67
+ }
68
+
44
69
  const READ_ONLY_TOOLS = new Set([
45
70
  "read",
46
71
  "grep",
@@ -56,7 +81,6 @@ const READ_ONLY_TOOLS = new Set([
56
81
  "devflow_usage_stats",
57
82
  "usage_stats",
58
83
  ]);
59
-
60
84
  const REQUIREMENT_DRAFT_TOOLS = new Set([...READ_ONLY_TOOLS, "ask"]);
61
85
  // A Todo update changes only the client-visible task list, never the project
62
86
  // or Devflow state. Permit it during planning so a harmless checklist cannot
@@ -414,6 +438,7 @@ const REQUIRED_SCRIPTS = [
414
438
  "workflow-orchestrator/scripts/phase_ownership.rb",
415
439
  "workflow-orchestrator/scripts/inspect_state.rb",
416
440
  "workflow-orchestrator/scripts/advance_state.rb",
441
+ "workflow-orchestrator/scripts/session_rollover.rb",
417
442
  "workflow-orchestrator/scripts/sync_artifacts.rb",
418
443
  "workflow-orchestrator/scripts/requirement_graph.rb",
419
444
  "workflow-orchestrator/scripts/acceptance_test_suite.rb",
@@ -840,9 +865,35 @@ export default function devflowCore(pi: ExtensionAPI) {
840
865
  let activeContract: ActiveExecutionContract | undefined;
841
866
  let contractAgentCalls = 0;
842
867
 
843
- function setControlState(ctx: { ui: { setStatus(key: string, text: string | undefined): void } }, state: SessionControlState) {
868
+ function controlGuide(state: SessionControlState): string[] {
869
+ const title = "Devflow 操作指引";
870
+ if (state.intent === "project-init" && state.phase === "planning") {
871
+ return [title, "初始化范围待确认:按界面完成选择;批准写入请回复“批准执行”。"];
872
+ }
873
+ if (state.intent === "requirement-draft" && state.phase === "planning") {
874
+ return [title, "需求梳理:直接补充需求正文、目标、边界和验收结果;此时不会推进实现。"];
875
+ }
876
+ if (state.phase === "waiting-user" || state.phase === "awaiting-approval") {
877
+ return [title, "当前节点等待审查:批准请回复“批准执行”;需修改请直接描述修改项。", "状态说明:/devflow explain-state"];
878
+ }
879
+ if (state.phase === "executing") {
880
+ return [title, `正在处理:${state.label}`, "请等待当前步骤输出;完成后会提示你审查。"];
881
+ }
882
+ if (state.phase === "blocked") {
883
+ return [title, "当前步骤被阻塞:使用 /devflow explain-state 查看证据与可恢复路径。", "仅在系统给出安全恢复计划后使用 /devflow recover。"];
884
+ }
885
+ return [title, "初始化项目:/devflow project-init", "创建功能:/devflow start <功能名> 查看状态:/devflow explain-state"];
886
+ }
887
+
888
+ function setControlState(ctx: {
889
+ ui: {
890
+ setStatus(key: string, text: string | undefined): void;
891
+ setWidget?(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
892
+ };
893
+ }, state: SessionControlState) {
844
894
  controlState = state;
845
895
  ctx.ui.setStatus("devflow-control", statusText(state));
896
+ ctx.ui.setWidget?.("devflow-operation-guide", controlGuide(state), { placement: "aboveEditor" });
846
897
  }
847
898
 
848
899
  function normalizeContractSegment(value: string, label: string): string {
@@ -862,11 +913,57 @@ export default function devflowCore(pi: ExtensionAPI) {
862
913
  const stateRevision = typeof state?.revision === "number" ? state.revision : undefined;
863
914
  const graphResult = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/requirement_graph.rb", ["inspect", "--feature", contract.feature, "--json"]));
864
915
  const graph = graphResult?.graph;
865
- const graphRevision = graph && typeof graph === "object" && typeof (graph as Record<string, unknown>).revision === "number"
866
- ? (graph as Record<string, number>).revision : undefined;
916
+ const graphRevision =
917
+ graph && typeof graph === "object" && typeof (graph as Record<string, unknown>).revision === "number"
918
+ ? (graph as Record<string, number>).revision
919
+ : undefined;
867
920
  return stateRevision === contract.stateRevision && graphRevision === contract.graphRevision;
868
921
  }
869
922
 
923
+ async function restoreActiveContract(cwd: string): Promise<ActiveExecutionContract | undefined> {
924
+ try {
925
+ const state = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]));
926
+ const feature = typeof state?.current_feature === "string" ? state.current_feature : undefined;
927
+ const rawStep = state?.current_step;
928
+ const step =
929
+ typeof rawStep === "string"
930
+ ? rawStep
931
+ : rawStep && typeof rawStep === "object" && typeof (rawStep as { id?: unknown }).id === "string"
932
+ ? (rawStep as { id: string }).id
933
+ : undefined;
934
+ if (!feature || !step) return undefined;
935
+ const contractsDir = path.join(cwd, ".planning", "contracts", feature);
936
+ if (!existsSync(contractsDir)) return undefined;
937
+ const candidates = readdirSync(contractsDir)
938
+ .filter(name => name.startsWith("EC-") && name.endsWith(".json"))
939
+ .map(name => path.join(contractsDir, name))
940
+ .sort((a, b) => lstatSync(b).mtimeMs - lstatSync(a).mtimeMs);
941
+ for (const candidate of candidates) {
942
+ let parsed: Record<string, unknown>;
943
+ try {
944
+ parsed = JSON.parse(readFileSync(candidate, "utf8")) as Record<string, unknown>;
945
+ } catch {
946
+ continue;
947
+ }
948
+ const contract = contractFromRecord(parsed);
949
+ if (!contract || contract.feature !== feature || contract.step !== step) continue;
950
+ const inspected = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", feature, "--step", step, "--json"]));
951
+ const currentRaw = inspected?.contract;
952
+ const current = currentRaw && typeof currentRaw === "object" ? contractFromRecord(currentRaw as Record<string, unknown>) : null;
953
+ if (!current) continue;
954
+ if (current.contractId !== contract.contractId) continue;
955
+ if (current.frozenHash !== contract.frozenHash) continue;
956
+ if (current.workflowFingerprint !== contract.workflowFingerprint) continue;
957
+ if (!(await contractIsFresh(cwd, contract))) continue;
958
+ contract.contractPath = candidate;
959
+ return contract;
960
+ }
961
+ } catch {
962
+ // 恢复失败不阻塞会话启动;对应步骤执行时会提示重新合成合同。
963
+ }
964
+ return undefined;
965
+ }
966
+
870
967
  async function collectProjectInitialization(ctx: {
871
968
  cwd: string;
872
969
  ui: {
@@ -1190,6 +1287,32 @@ export default function devflowCore(pi: ExtensionAPI) {
1190
1287
 
1191
1288
  let activeWorkflowTurn = false;
1192
1289
  let pendingProjectInitialization: ProjectInitializationRequest | undefined;
1290
+ let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
1291
+
1292
+ async function enqueueControlledRolloverRequest(cwd: string, rolloverId: string, sourceSessionId: string) {
1293
+ const requestPath = path.join(cwd, ".planning", "rollover-request.json");
1294
+ const temporary = `${requestPath}.${randomUUID()}.tmp`;
1295
+ await writeFile(temporary, JSON.stringify({ schema_version: 1, rollover_id: rolloverId, source_session_id: sourceSessionId }) + "\n", "utf8");
1296
+ await rename(temporary, requestPath);
1297
+ }
1298
+
1299
+ async function requestControlledRollover(ctx: { cwd: string; sessionManager: { getSessionId(): string } }, snapshot: Record<string, unknown>) {
1300
+ const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
1301
+ if (revision === undefined) throw new Error("Cannot prepare session rollover without a STATE revision.");
1302
+ const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
1303
+ "prepare", "--write", "--json", "--expected-revision", String(revision),
1304
+ "--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
1305
+ ]);
1306
+ const payload = parseJsonResult(prepared);
1307
+ if (!prepared.ok || !payload || typeof payload.revision !== "number") {
1308
+ throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
1309
+ }
1310
+ const rollover = payload.rollover as Record<string, unknown> | undefined;
1311
+ const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
1312
+ if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
1313
+ await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
1314
+ pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
1315
+ }
1193
1316
 
1194
1317
  async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
1195
1318
  activeWorkflowTurn = true;
@@ -1211,7 +1334,71 @@ export default function devflowCore(pi: ExtensionAPI) {
1211
1334
  return;
1212
1335
  }
1213
1336
  ctx.ui.setStatus("devflow", "Devflow ready");
1337
+ const restored = await restoreActiveContract(ctx.cwd);
1338
+ if (restored) {
1339
+ activeContract = restored;
1340
+ contractAgentCalls = 0;
1341
+ ctx.ui.notify(`已恢复执行合同 ${restored.contractId}(${restored.feature}/${restored.step});步骤推进后自动失效。`, "info");
1342
+ }
1214
1343
  setControlState(ctx, initialControlState());
1344
+ // Recovery never searches the handoff directory. STATE.rollover is the
1345
+ // sole authority: only the original source session may resume a prepared
1346
+ // transition, and only the already-recorded target may consume a claim.
1347
+ const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
1348
+ const rolloverState = parseJsonResult(rolloverResult);
1349
+ const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
1350
+ const currentSessionId = ctx.sessionManager.getSessionId();
1351
+ if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && rollover.source_session_id === currentSessionId && typeof rollover.id === "string") {
1352
+ const workflow = await inspectWorkflow(ctx.cwd);
1353
+ if (workflow.result.ok && workflow.snapshot) {
1354
+ pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
1355
+ await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
1356
+ ctx.ui.notify("已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
1357
+ }
1358
+ } else if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "claimed" && rollover.target_session_id === currentSessionId) {
1359
+ const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
1360
+ "consume", "--write", "--json", "--expected-revision", String(rolloverState.revision), "--target-session-id", currentSessionId,
1361
+ ]);
1362
+ const after = await inspectWorkflow(ctx.cwd);
1363
+ if (consumed.ok && after.result.ok && after.snapshot) {
1364
+ setControlState(ctx, executingState("恢复已认领的干净会话"));
1365
+ await triggerWorkflow(after.snapshot, "approval");
1366
+ } else if (!consumed.ok) {
1367
+ ctx.ui.notify(`已认领的会话交接无法恢复;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
1368
+ }
1369
+ }
1370
+ });
1371
+
1372
+ pi.on("session_before_switch", async (event) => {
1373
+ // A controlled rollover must always create a fresh session. Other session
1374
+ // navigation remains OMP-owned and never consumes a Devflow handoff.
1375
+ if (pendingRollover && event.reason !== "new") return { cancel: true };
1376
+ });
1377
+
1378
+ pi.on("session_switch", async (event, ctx) => {
1379
+ if (!pendingRollover || event.reason !== "new") return;
1380
+ const pending = pendingRollover;
1381
+ const targetSessionId = ctx.sessionManager.getSessionId();
1382
+ const claimed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
1383
+ "claim", "--write", "--json", "--expected-revision", String(pending.preparedRevision), "--target-session-id", targetSessionId,
1384
+ ]);
1385
+ const claimedPayload = parseJsonResult(claimed);
1386
+ if (!claimed.ok || !claimedPayload || typeof claimedPayload.revision !== "number") {
1387
+ ctx.ui.notify(`Devflow 会话交接认领失败;工作流保持冻结:${claimed.stderr || claimed.stdout}`, "error");
1388
+ return;
1389
+ }
1390
+ const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
1391
+ "consume", "--write", "--json", "--expected-revision", String(claimedPayload.revision), "--target-session-id", targetSessionId,
1392
+ ]);
1393
+ if (!consumed.ok) {
1394
+ ctx.ui.notify(`Devflow 会话交接消费失败;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
1395
+ return;
1396
+ }
1397
+ pendingRollover = undefined;
1398
+ activeContract = undefined;
1399
+ contractAgentCalls = 0;
1400
+ setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
1401
+ await triggerWorkflow(pending.snapshot, "approval");
1215
1402
  });
1216
1403
 
1217
1404
  pi.on("before_agent_start", async event => ({
@@ -1277,7 +1464,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1277
1464
  return { handled: true };
1278
1465
  }
1279
1466
  const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
1280
- "--write", "--json", "--expected-revision", String(revision),
1467
+ "--write",
1468
+ "--json",
1469
+ "--expected-revision",
1470
+ String(revision),
1281
1471
  ...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
1282
1472
  ]);
1283
1473
  if (!result.ok) {
@@ -1286,13 +1476,21 @@ export default function devflowCore(pi: ExtensionAPI) {
1286
1476
  return { handled: true };
1287
1477
  }
1288
1478
  const after = await inspectWorkflow(ctx.cwd);
1479
+ activeContract = undefined;
1480
+ contractAgentCalls = 0;
1289
1481
  if (!after.result.ok || !after.snapshot || after.snapshot.action === "workflow_complete") {
1290
1482
  setControlState(ctx, initialControlState());
1291
1483
  ctx.ui.notify(after.snapshot?.action === "workflow_complete" ? "工作流已完成。" : "已推进,但读取下一步骤失败。", "info");
1292
1484
  return { handled: true };
1293
1485
  }
1294
- setControlState(ctx, executingState("执行已批准的下一工作流步骤"));
1295
- await triggerWorkflow(after.snapshot, "approval");
1486
+ setControlState(ctx, waitingUserState("正在准备干净会话以执行下一工作流步骤"));
1487
+ try {
1488
+ await requestControlledRollover(ctx, after.snapshot);
1489
+ ctx.ui.notify("当前节点的证据已固定;本回合结束后将自动切换到干净会话并继续下一节点。", "info");
1490
+ } catch (error) {
1491
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "会话轮换准备失败" });
1492
+ ctx.ui.notify(`未开始下一节点:会话轮换准备失败:${String(error)}`, "error");
1493
+ }
1296
1494
  return { handled: true };
1297
1495
  }
1298
1496
  }
@@ -1538,9 +1736,11 @@ export default function devflowCore(pi: ExtensionAPI) {
1538
1736
  });
1539
1737
 
1540
1738
  pi.registerTool({
1739
+
1541
1740
  name: "devflow_requirement_graph",
1542
1741
  label: "Devflow Requirement Graph",
1543
- description: "Submit bounded requirement-graph candidates or inspect/validate the Core-owned canonical graph.",
1742
+ description: "Submit bounded requirement-graph candidates or inspect and validate the Core-owned canonical graph.",
1743
+
1544
1744
  parameters: z.object({
1545
1745
  action: z.enum(["propose", "inspect", "validate", "context", "confirm"]),
1546
1746
  feature: z.string().min(1),
@@ -1552,7 +1752,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1552
1752
  read: z.boolean().optional(),
1553
1753
  delta: z.object({
1554
1754
  op: z.enum(["add", "link", "update"]),
1555
- kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "test_case"]).optional(),
1755
+ kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "metric", "read_model", "test_case"]).optional(),
1556
1756
  id: z.string().min(1).optional(),
1557
1757
  data: z.record(z.string(), z.unknown()).optional(),
1558
1758
  from: z.string().min(1).optional(),
@@ -1596,23 +1796,49 @@ export default function devflowCore(pi: ExtensionAPI) {
1596
1796
  const node = Array.isArray(inspected?.graph?.nodes)
1597
1797
  ? inspected.graph.nodes.find((candidate: unknown) => typeof candidate === "object" && candidate !== null && (candidate as { id?: unknown }).id === params.nodeId) as { kind?: unknown; data?: unknown } | undefined
1598
1798
  : undefined;
1599
- const architectureDecision = node?.kind === "decision" && typeof node?.data === "object" && node.data !== null && (node.data as { decision_type?: unknown }).decision_type === "architecture_option_comparison";
1600
- const architectureRisk = node?.kind === "risk" && typeof node?.data === "object" && node.data !== null && (node.data as { risk_type?: unknown }).risk_type === "architecture_option_risk";
1601
- if (node?.kind === "workload_shape" || node?.kind === "workload_constraint" || architectureDecision || architectureRisk) {
1799
+ const architectureDecision =
1800
+ node?.kind === "decision" && typeof node?.data === "object" && node.data !== null && (node.data as { decision_type?: unknown }).decision_type === "architecture_option_comparison";
1801
+ const architectureRisk =
1802
+ node?.kind === "risk" && typeof node?.data === "object" && node.data !== null && (node.data as { risk_type?: unknown }).risk_type === "architecture_option_risk";
1803
+ const metricNode = node?.kind === "metric";
1804
+ const readModelNode = node?.kind === "read_model";
1805
+ if (node?.kind === "workload_shape" || node?.kind === "workload_constraint" || metricNode || readModelNode || architectureDecision || architectureRisk) {
1602
1806
  const preview = JSON.stringify(node.data ?? {}, null, 2);
1603
- const decisionDialog = architectureDecision;
1604
- const riskDialog = architectureRisk;
1605
1807
  const confirmed = await ctx.ui.confirm(
1606
- decisionDialog ? "确认架构方案取舍" : riskDialog ? "确认架构风险处理" : "确认工作负载事实或缺口",
1808
+ architectureDecision
1809
+ ? "确认架构方案取舍"
1810
+ : architectureRisk
1811
+ ? "确认架构风险处理"
1812
+ : metricNode
1813
+ ? "确认业务指标定义"
1814
+ : readModelNode
1815
+ ? "确认读模型定义"
1816
+ : "确认工作负载事实或缺口",
1607
1817
  [
1608
1818
  `Feature:${params.feature}`,
1609
1819
  `节点:${params.nodeId}`,
1610
- `类型:${decisionDialog ? "架构方案比较" : riskDialog ? "架构风险处理" : node.kind === "workload_shape" ? "工作负载形态" : "工作负载约束"}`,
1611
- decisionDialog
1820
+ `类型:${
1821
+ architectureDecision
1822
+ ? "架构方案比较"
1823
+ : architectureRisk
1824
+ ? "架构风险处理"
1825
+ : metricNode
1826
+ ? "业务指标"
1827
+ : readModelNode
1828
+ ? "读模型"
1829
+ : node?.kind === "workload_shape"
1830
+ ? "工作负载形态"
1831
+ : "工作负载约束"
1832
+ }`,
1833
+ architectureDecision
1612
1834
  ? "确认的含义是:你已核对候选方案、证据与取舍,并接受其中 recommendation_option_id 作为本次架构选择。"
1613
- : riskDialog
1614
- ? "确认的含义是:你已核对失败模式、影响、处理方式、负责人和复核条件;它不代表生产风险从此消失。"
1615
- : "确认的含义仅是:该事实或缺口、来源与责任归属已被你核对;它不代表容量已经满足,也不会替你选择实现方案。",
1835
+ : architectureRisk
1836
+ ? "确认的含义是:你已核对风险陈述、缓解策略与剩余风险,并接受该风险登记结果。"
1837
+ : metricNode
1838
+ ? "确认的含义是:你已核对该业务指标的口径、粒度、包含/排除规则与归属;它代表可追踪的业务事实,不只是文本标签。"
1839
+ : readModelNode
1840
+ ? "确认的含义是:你已核对该读模型的投影语义、消费边界与查询责任;它代表 Core 认可的读侧契约。"
1841
+ : "确认的含义仅是:该事实或缺口、来源与责任归属已被你核对;它不代表容量已经满足,也不会替你选择实现方案。",
1616
1842
  "待确认内容:",
1617
1843
  preview.length > 8_000 ? `${preview.slice(0, 8_000)}\n…(内容过长,已截断)` : preview,
1618
1844
  ].join("\n\n"),
@@ -2029,19 +2255,12 @@ export default function devflowCore(pi: ExtensionAPI) {
2029
2255
  const payload = parseJsonResult(inspected);
2030
2256
  const raw = payload?.contract;
2031
2257
  if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
2032
- const record = raw as Record<string, unknown>;
2033
- const strings = (key: string): string[] => Array.isArray(record[key]) ? record[key].filter((item): item is string => typeof item === "string") : [];
2034
- const contract: ActiveExecutionContract = {
2035
- contractId: String(record.contract_id ?? ""), feature: String(record.feature ?? ""), step: String(record.step ?? ""),
2036
- stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
2037
- graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
2038
- allowedPaths: strings("allowed_paths"), allowedAgents: strings("allowed_agents"), allowedTools: strings("allowed_tools"), checkerIds: strings("checker_ids"),
2039
- toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
2040
- frozenHash: String(record.frozen_hash ?? ""), acceptance: strings("acceptance"), allowedGraphDelta: strings("allowed_graph_delta"), onUnknown: "block_and_escalate",
2041
- };
2042
- if (!contract.contractId || !contract.frozenHash) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
2258
+ const contract = contractFromRecord(raw as Record<string, unknown>);
2259
+ if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
2043
2260
  if (input.action === "inspect") return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
2044
- if (controlState.phase !== "planning") return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "Contracts may only be activated from the planning phase." });
2261
+ if (!["planning", "waiting-user", "awaiting-approval"].includes(controlState.phase)) {
2262
+ return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "Contracts may only be activated from the planning phase or while the step waits for user review." });
2263
+ }
2045
2264
  const confirmed = await ctx.ui.confirm(
2046
2265
  "激活 Devflow 执行合同",
2047
2266
  [
@@ -20,6 +20,8 @@ from test_command_policy import validate_test_command
20
20
 
21
21
  FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
22
22
  AUTOMATION_MODES = {"automated", "manual", "blocked"}
23
+ EVIDENCE_STRATEGIES = {"replay", "causal", "observation"}
24
+ MAX_EVIDENCE_FILE_BYTES = 25 * 1024 * 1024
23
25
 
24
26
 
25
27
  def utc_now() -> str:
@@ -142,6 +144,38 @@ def file_fingerprint(path: Path) -> str | None:
142
144
  return digest(path.read_bytes()) if path.is_file() else None
143
145
 
144
146
 
147
+ def evidence_artifacts(root: Path, value: Any) -> tuple[list[dict[str, Any]], list[str]]:
148
+ """Hash causal evidence from bounded project-relative files only."""
149
+ if not isinstance(value, list) or not value:
150
+ return [], ["causal verification requires non-empty evidence_files"]
151
+ root_real = root.resolve()
152
+ artifacts: list[dict[str, Any]] = []
153
+ errors: list[str] = []
154
+ for raw in value:
155
+ if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
156
+ errors.append("evidence_files entries must be non-empty relative paths")
157
+ continue
158
+ candidate = Path(raw)
159
+ if candidate.is_absolute() or ".." in candidate.parts:
160
+ errors.append(f"evidence file escapes project root: {raw}")
161
+ continue
162
+ resolved = (root_real / candidate).resolve()
163
+ try:
164
+ relative = resolved.relative_to(root_real)
165
+ except ValueError:
166
+ errors.append(f"evidence file escapes project root: {raw}")
167
+ continue
168
+ if not resolved.is_file():
169
+ errors.append(f"causal evidence file is missing: {raw}")
170
+ continue
171
+ size = resolved.stat().st_size
172
+ if size > MAX_EVIDENCE_FILE_BYTES:
173
+ errors.append(f"causal evidence file exceeds {MAX_EVIDENCE_FILE_BYTES} bytes: {raw}")
174
+ continue
175
+ artifacts.append({"path": relative.as_posix(), "sha256": digest(resolved.read_bytes()), "bytes": size})
176
+ return artifacts, errors
177
+
178
+
145
179
  def run_http_case(source: dict[str, Any], adapter: dict[str, Any]) -> tuple[bool, int, str]:
146
180
  execution = source["execution"]
147
181
  request_spec = execution["request"]
@@ -248,6 +282,11 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
248
282
  if source is None:
249
283
  errors.append(f"unknown TC reference: {tc_id}")
250
284
  continue
285
+ strategy = source.get("evidence_strategy")
286
+ if strategy is not None and strategy not in EVIDENCE_STRATEGIES:
287
+ errors.append(f"{label}.source evidence_strategy is invalid")
288
+ if strategy == "observation" and source.get("automation_mode") != "blocked":
289
+ errors.append(f"{label}.observation source must be blocked")
251
290
  mode = entry.get("mode")
252
291
  if mode not in AUTOMATION_MODES:
253
292
  errors.append(f"{label}.mode is invalid")
@@ -278,6 +317,9 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
278
317
  errors.append(f"{label}.adapter.setup: {setup_error}")
279
318
  else:
280
319
  errors.append(f"{label}.adapter kind must be command or http")
320
+ if strategy == "causal":
321
+ _artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
322
+ errors.extend(f"{label}.{error}" for error in artifact_errors)
281
323
  elif mode == "manual":
282
324
  if not isinstance(entry.get("owner"), str) or not entry["owner"].strip() or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
283
325
  errors.append(f"{label}.manual requires owner and reason")
@@ -303,6 +345,7 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
303
345
  for entry in bindings["cases"]:
304
346
  tc_id = entry["tc_id"]
305
347
  mode = entry["mode"]
348
+ strategy = cases[tc_id].get("evidence_strategy")
306
349
  if mode == "automated":
307
350
  started_at = utc_now()
308
351
  adapter = entry["adapter"]
@@ -326,6 +369,13 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
326
369
  result = {"tc_id": tc_id, "status": "passed" if passed else "failed", "mode": mode, "adapter_kind": "http",
327
370
  "adapter_fingerprint": digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
328
371
  "finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
372
+ if strategy == "causal":
373
+ artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
374
+ if artifact_errors:
375
+ result["status"] = "failed"
376
+ result["evidence_error"] = "; ".join(artifact_errors)
377
+ else:
378
+ result["evidence_artifacts"] = artifacts
329
379
  results.append(result)
330
380
  elif mode == "manual":
331
381
  results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "reason": entry["reason"],
@@ -504,7 +504,15 @@ result =
504
504
  next_intent_requested = false
505
505
  current_revision = Integer(state.fetch("revision", 0))
506
506
 
507
- if !options[:expected_revision].nil? && current_revision != options[:expected_revision]
507
+ if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
508
+ result = fail_result(
509
+ "session_rollover_in_progress",
510
+ "rollover" => state["rollover"],
511
+ "message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
512
+ )
513
+ end
514
+
515
+ if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
508
516
  result = fail_result(
509
517
  "stale_revision",
510
518
  "expected_revision" => options[:expected_revision],
@@ -554,6 +554,8 @@ def tc_candidate(api: dict[str, Any], graph: dict[str, Any], key: str, obligatio
554
554
  "steps": ["发送 execution.request 中定义的 HTTP 请求"],
555
555
  "oracle": oracle,
556
556
  "automation_mode": "automated",
557
+ "evidence_strategy": "replay",
558
+ "replay_scope": "api",
557
559
  "api_refs": [api["id"]],
558
560
  "execution": execution,
559
561
  }
@@ -96,13 +96,29 @@ module ArchitectureBrief
96
96
  assessment["triggers"].each { |trigger| lines << "- `#{trigger["code"]}` — #{trigger["node_id"]}" }
97
97
  end
98
98
 
99
- lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 执行 | 操作 | 扇出 | 数据影响 | 外部调用 |", "|---|---|---|---|---|---|---|"])
99
+ lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 类型 | 一致性 | 执行 | 操作 | 扇出 | 数据影响 |", "|---|---|---|---|---|---|---|---|"])
100
100
  if workload_shapes.empty?
101
- lines << "| — | — | — | — | — | — | — |"
101
+ lines << "| — | — | — | — | — | — | — | — |"
102
102
  else
103
103
  workload_shapes.each do |node|
104
104
  facts = node.fetch("data", {}).fetch("facts", {})
105
- lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} | #{text(Array(facts["external_calls"]).join(", "))} |"
105
+ lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["archetype"] || "legacy")} | #{text(facts["consistency"] || "—")} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} |"
106
+ end
107
+ end
108
+
109
+ metrics = Array(graph["nodes"]).select { |node| node["kind"] == "metric" }
110
+ read_models = Array(graph["nodes"]).select { |node| node["kind"] == "read_model" }
111
+ lines.concat(["", "## 统计指标与读模型", "", "| 节点 | 状态 | 定义/服务对象 | 时间与修正 |", "|---|---|---|---|"])
112
+ if metrics.empty? && read_models.empty?
113
+ lines << "| — | — | — | — |"
114
+ else
115
+ metrics.each do |node|
116
+ data = node.fetch("data", {})
117
+ lines << "| #{node["id"]} | #{node["status"]} | 指标:#{text(data["name"])};粒度:#{text(data["grain"])} | #{text(data["time_basis"])} / #{text(data["timezone"])};#{text(data["correction_policy"])} |"
118
+ end
119
+ read_models.each do |node|
120
+ data = node.fetch("data", {})
121
+ lines << "| #{node["id"]} | #{node["status"]} | 读模型:#{text(data["projection_mode"])};服务:#{text(Array(data["serves"]).join(", "))} | 重建:#{text(data.dig("rebuild", "method"))};迟到数据:#{text(data["late_data_policy"])} |"
106
122
  end
107
123
  end
108
124
 
@@ -13,6 +13,10 @@ module ArchitectureConstraints
13
13
  OPERATIONS = %w[read write batch_read batch_write].freeze
14
14
  FANOUTS = %w[none bounded unbounded unknown].freeze
15
15
  DATA_EFFECTS = %w[none update append_only migration unknown].freeze
16
+ ARCHETYPES = %w[transactional_command operational_query analytics_query analytics_projection batch_processing external_integration].freeze
17
+ DATA_ROLES = %w[source_of_truth derived_read_model external_projection].freeze
18
+ CONSISTENCIES = %w[strong read_your_write bounded_staleness eventual unknown].freeze
19
+ EVENT_TIMES = %w[required not_applicable unknown].freeze
16
20
  METRICS = %w[peak_qps data_growth p95_latency rpo rto].freeze
17
21
  SOURCE_STATUSES = %w[measured contracted approved_assumption unknown].freeze
18
22
  EVIDENCE_KINDS = %w[source_file cp_record external_measurement].freeze
@@ -52,6 +56,26 @@ module ArchitectureConstraints
52
56
  errors_for_enum(errors, facts, "data_effect", DATA_EFFECTS)
53
57
  external_calls = facts["external_calls"]
54
58
  errors << "external_calls must be an array" unless external_calls.nil? || external_calls.is_a?(Array)
59
+ # `archetype` is optional for pre-v5 graphs. Once supplied it is a
60
+ # contract, not prose: analytics shapes must say what they serve and how
61
+ # their time semantics behave.
62
+ if facts.key?("archetype")
63
+ errors_for_enum(errors, facts, "archetype", ARCHETYPES)
64
+ errors_for_enum(errors, facts, "data_role", DATA_ROLES)
65
+ errors_for_enum(errors, facts, "consistency", CONSISTENCIES)
66
+ time = facts["time_semantics"]
67
+ errors << "time_semantics must be an object" unless time.is_a?(Hash)
68
+ errors_for_enum(errors, time || {}, "event_time", EVENT_TIMES)
69
+ errors << "time_semantics.timezone is required" unless present?(time && time["timezone"])
70
+ if %w[analytics_query analytics_projection].include?(facts["archetype"])
71
+ refs = shape["metric_refs"]
72
+ errors << "analytics workloads require non-empty metric_refs" unless refs.is_a?(Array) && !refs.empty?
73
+ end
74
+ if facts["archetype"] == "analytics_projection"
75
+ refs = shape["read_model_refs"]
76
+ errors << "analytics projections require non-empty read_model_refs" unless refs.is_a?(Array) && !refs.empty?
77
+ end
78
+ end
55
79
  source_refs = shape["source_refs"]
56
80
  errors << "source_refs must be a non-empty array" unless source_refs.is_a?(Array) && !source_refs.empty?
57
81
  Array(source_refs).each { |ref| errors << "source_refs must reference REQ/BEH/API" unless reference?(ref, %w[REQ BEH API]) }