@guanlin4924/devflow-pi 0.1.25 → 0.1.27

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.
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.27
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-*` 验收测试设计,再生成变更清单:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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
  [
@@ -2184,9 +2403,11 @@ export default function devflowCore(pi: ExtensionAPI) {
2184
2403
  label: "Devflow Transition",
2185
2404
  description: "Apply one validated delivery-state transition. This is the only supported STATE.md mutation path.",
2186
2405
  parameters: z.object({
2187
- action: z.enum(["complete-step", "confirm-checkpoint", "checkpoint-decision", "enter-failure-branch"]),
2406
+ action: z.enum(["complete-step", "confirm-checkpoint", "checkpoint-decision", "enter-failure-branch", "cancel-feature"]),
2188
2407
  step: z.string().optional(),
2189
2408
  decision: z.string().optional(),
2409
+ reason: z.string().optional(),
2410
+ confirm: z.literal(true).optional(),
2190
2411
  state: z.string().optional(),
2191
2412
  expectedRevision: z.number().int().min(0),
2192
2413
  }),
@@ -2219,6 +2440,19 @@ export default function devflowCore(pi: ExtensionAPI) {
2219
2440
  return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "decision is required" });
2220
2441
  }
2221
2442
  args.push("--checkpoint-decision", params.decision);
2443
+ } else if (params.action === "cancel-feature") {
2444
+ const inspected = await inspectWorkflow(ctx.cwd);
2445
+ const snapshot = inspected.snapshot;
2446
+ const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : "当前需求";
2447
+ const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
2448
+ if (!inspected.result.ok || revision === undefined) return renderResult(inspected.result, { action: params.action });
2449
+ if (!params.reason?.trim()) return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "reason is required" });
2450
+ const confirmed = await ctx.ui.confirm(
2451
+ "确认弃用当前需求",
2452
+ `需求:${feature}\n原因:${params.reason.trim()}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
2453
+ );
2454
+ if (!confirmed) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "User declined feature cancellation." });
2455
+ args.push("--cancel", "--cancel-reason", params.reason.trim());
2222
2456
  } else {
2223
2457
  args.push("--enter-failure-branch");
2224
2458
  }
@@ -2382,6 +2616,42 @@ export default function devflowCore(pi: ExtensionAPI) {
2382
2616
  ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后明确回复“批准执行”或提出修改意见。", "info");
2383
2617
  return;
2384
2618
  }
2619
+ if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
2620
+ const inspected = await inspectWorkflow(ctx.cwd);
2621
+ const snapshot = inspected.snapshot;
2622
+ const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
2623
+ const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
2624
+ const status = typeof snapshot?.status === "string" ? snapshot.status : undefined;
2625
+ if (!inspected.result.ok || revision === undefined || !feature) {
2626
+ ctx.ui.notify(`无法读取当前需求状态:${inspected.result.stderr || inspected.result.stdout}`, "error");
2627
+ return;
2628
+ }
2629
+ if (!status || ["completed", "cancelled", "failed"].includes(status)) {
2630
+ ctx.ui.notify(`当前没有可弃用的进行中需求(status=${status || "unknown"})。`, "info");
2631
+ return;
2632
+ }
2633
+ const reason = trimmed.split(/\s+/, 2)[1]?.trim() || "用户主动终止当前需求";
2634
+ const confirmed = await ctx.ui.confirm(
2635
+ "确认弃用当前需求",
2636
+ `需求:${feature}\n原因:${reason}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
2637
+ );
2638
+ if (!confirmed) {
2639
+ ctx.ui.notify("已取消弃用操作;状态保持不变。", "info");
2640
+ return;
2641
+ }
2642
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
2643
+ "--write", "--json", "--cancel", "--cancel-reason", reason, "--expected-revision", String(revision),
2644
+ ]);
2645
+ if (!result.ok) {
2646
+ ctx.ui.notify(`弃用当前需求失败:${result.stderr || result.stdout}`, "error");
2647
+ return;
2648
+ }
2649
+ activeContract = undefined;
2650
+ contractAgentCalls = 0;
2651
+ setControlState(ctx, initialControlState());
2652
+ ctx.ui.notify(`需求“${feature}”已弃用;现有工件已保留。现在可以创建新需求。`, "info");
2653
+ return;
2654
+ }
2385
2655
  if (trimmed === "project-init") {
2386
2656
  const initialization = await collectProjectInitialization(ctx);
2387
2657
  if (!initialization) return;
@@ -2453,7 +2723,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2453
2723
  }
2454
2724
  const existing = await inspectWorkflow(ctx.cwd);
2455
2725
  const existingFeature = existing.snapshot?.current_feature;
2456
- const previousWorkflowComplete = existing.snapshot?.action === "workflow_complete";
2726
+ const previousWorkflowComplete = ["workflow_complete", "workflow_cancelled"].includes(existing.snapshot?.action as string);
2457
2727
  if (
2458
2728
  !previousWorkflowComplete &&
2459
2729
  typeof existingFeature === "string" &&
@@ -2534,7 +2804,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2534
2804
  return;
2535
2805
  }
2536
2806
  ctx.ui.notify(
2537
- "Usage: /devflow doctor | status | explain-state | recover | project-init | start <feature>|<phase> | resume",
2807
+ "Usage: /devflow doctor | status | explain-state | recover | abandon [reason] | project-init | start <feature>|<phase> | resume",
2538
2808
  "warning",
2539
2809
  );
2540
2810
  },
@@ -21,6 +21,8 @@ options = {
21
21
  checkpoint_decision: nil,
22
22
  enter_failure_branch: false,
23
23
  exit_failure_branch: false,
24
+ cancel: false,
25
+ cancel_reason: nil,
24
26
  expected_revision: nil,
25
27
  json: false
26
28
  }
@@ -34,6 +36,8 @@ OptionParser.new do |opts|
34
36
  opts.on("--checkpoint-decision VALUE", "Set checkpoint decision, e.g. approved, rejected, needs_fix") { |value| options[:checkpoint_decision] = value }
35
37
  opts.on("--enter-failure-branch", "Transition into the configured failure branch for the current step") { options[:enter_failure_branch] = true }
36
38
  opts.on("--exit-failure-branch", "Return from a completed failure branch to its configured source flow") { options[:exit_failure_branch] = true }
39
+ opts.on("--cancel", "Cancel the active feature without marking pending steps completed") { options[:cancel] = true }
40
+ opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
37
41
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
38
42
  opts.on("--json", "Print JSON output") { options[:json] = true }
39
43
  end.parse!
@@ -504,7 +508,15 @@ result =
504
508
  next_intent_requested = false
505
509
  current_revision = Integer(state.fetch("revision", 0))
506
510
 
507
- if !options[:expected_revision].nil? && current_revision != options[:expected_revision]
511
+ if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
512
+ result = fail_result(
513
+ "session_rollover_in_progress",
514
+ "rollover" => state["rollover"],
515
+ "message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
516
+ )
517
+ end
518
+
519
+ if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
508
520
  result = fail_result(
509
521
  "stale_revision",
510
522
  "expected_revision" => options[:expected_revision],
@@ -519,6 +531,31 @@ result =
519
531
  action_description = "Checkpoint decision set to: #{options[:checkpoint_decision]}"
520
532
  end
521
533
 
534
+ if result.nil? && options[:cancel]
535
+ if %w[completed cancelled failed].include?(state["status"])
536
+ result = fail_result("feature_not_active", "status" => state["status"])
537
+ elsif options[:cancel_reason].to_s.strip.empty?
538
+ result = fail_result("cancel_reason_required")
539
+ else
540
+ state["status"] = "cancelled"
541
+ state["cancel_reason"] = options[:cancel_reason].to_s.strip
542
+ state["cancelled_at"] = Date.today.to_s
543
+ # A cancelled feature is terminal. Preserve completed steps and the
544
+ # previous step for history, but clear current/pending work so the
545
+ # inspector can expose a terminal state and ownership can be released.
546
+ state["cancelled_step"] = state["current_step"]
547
+ state["current_step"] = nil
548
+ state["pending_steps"] = []
549
+ state.delete("checkpoint")
550
+ state.delete("checkpoint_label")
551
+ state.delete("checkpoint_decision")
552
+ state.delete("rollover")
553
+ changed = true
554
+ actions << "feature_cancelled"
555
+ action_description = "Feature cancelled: #{state["current_feature"]}. Reason: #{state["cancel_reason"]}."
556
+ end
557
+ end
558
+
522
559
  if result.nil? && options[:complete_step]
523
560
  step_id = options[:complete_step]
524
561
  step = step_by_id[step_id]
@@ -31,6 +31,10 @@ module DevflowControlIO
31
31
  when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
32
32
  when :registry then normalized == ".planning/artifacts/index.yaml"
33
33
  when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
34
+ # Feature names are user-facing and may be Unicode. `cleanpath`
35
+ # and the parent-traversal check above own containment; this
36
+ # matcher only fixes the handoff namespace and immutable name.
37
+ when :handoff then normalized.match?(%r{\A\.planning/handoffs/[^/]+/ROL-[0-9a-f-]+\.json\z})
34
38
  else false
35
39
  end
36
40
  raise InvalidControlPath, "invalid #{kind} control path" unless allowed
@@ -13,7 +13,7 @@ require_relative "requirement_graph"
13
13
  POLICY = {
14
14
  "requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
15
15
  "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
16
- "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
16
+ "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
17
17
  "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
18
18
  "acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
19
19
  "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
@@ -473,6 +473,28 @@ result =
473
473
  action: "workflow_complete",
474
474
  reason: "completed_status_with_no_pending_steps"
475
475
  }
476
+ elsif pending_ids.empty? && %w[cancelled failed].include?(state["status"])
477
+ {
478
+ current_flow: state["current_flow"],
479
+ current_feature: state["current_feature"],
480
+ current_phase: state["current_phase"],
481
+ status: state["status"],
482
+ cancel_reason: state["cancel_reason"],
483
+ cancelled_step: state["cancelled_step"],
484
+ completed_steps: completed_ids,
485
+ pending_steps: pending_ids,
486
+ current_step: state["current_step"] && step_payload(step_by_id[state["current_step"]], state, root, artifact_by_path),
487
+ next_step: nil,
488
+ workflow_config: workflow_path,
489
+ artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
490
+ "path" => artifact_registry_path,
491
+ "error" => artifact_registry_error,
492
+ "recovery" => artifact_registry_error ? "Call devflow_artifact with action=sync." : nil,
493
+ "current_outputs_registered" => 0
494
+ ),
495
+ action: state["status"] == "cancelled" ? "workflow_cancelled" : "workflow_failed",
496
+ reason: "terminal_status_with_no_pending_steps"
497
+ }
476
498
  else
477
499
  fail_result("unknown_current_step", current_step: state["current_step"])
478
500
  end
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Controlled, content-addressed session handoff state. This script is the only
5
+ # writer for STATE.rollover; callers must hold no assumptions about a
6
+ # filesystem "latest" handoff. STATE is the authority and immutable handoff
7
+ # JSON is the evidence it points to.
8
+
9
+ require "digest"
10
+ require "json"
11
+ require "optparse"
12
+ require "securerandom"
13
+ require "time"
14
+ require "yaml"
15
+ require_relative "control_io"
16
+
17
+ options = { json: false, write: false }
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: session_rollover.rb prepare|claim|consume|inspect [options]"
20
+ opts.on("--write", "Apply the requested transition") { options[:write] = true }
21
+ opts.on("--json", "Print JSON") { options[:json] = true }
22
+ opts.on("--expected-revision N", Integer, "STATE revision compare-and-swap") { |v| options[:expected_revision] = v }
23
+ opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
24
+ opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
25
+ opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
26
+ end
27
+ action = ARGV.shift
28
+ parser.parse!
29
+
30
+ def fail_result(reason, details = {})
31
+ { "ok" => false, "reason" => reason }.merge(details)
32
+ end
33
+
34
+ def parse_state(path)
35
+ raw = File.read(path)
36
+ match = raw.match(/\A---\n(.*?)\n---\n/m)
37
+ raise "STATE.md is missing YAML front matter" unless match
38
+ [YAML.safe_load(match[1], permitted_classes: [], aliases: false), raw]
39
+ end
40
+
41
+ def write_state(path, state, previous)
42
+ body = previous.sub(/\A---\n.*?\n---\n/m, "")
43
+ front = state.to_yaml.sub(/\A---\n/, "")
44
+ DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
45
+ end
46
+
47
+ def valid_session_id?(value)
48
+ value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
49
+ end
50
+
51
+ def iso_now
52
+ Time.now.utc.iso8601(3)
53
+ end
54
+
55
+ root = Dir.pwd
56
+ state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
57
+ result = catch(:transition_done) do
58
+ lock = DevflowControlIO.acquire_project_lock(root)
59
+ begin
60
+ unless File.exist?(state_path)
61
+ result = fail_result("missing_state")
62
+ throw :transition_done, result
63
+ end
64
+ state, raw = parse_state(state_path)
65
+ revision = Integer(state.fetch("revision", 0))
66
+ if !options[:expected_revision].nil? && revision != options[:expected_revision]
67
+ result = fail_result("stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => revision)
68
+ throw :transition_done, result
69
+ end
70
+ rollover = state["rollover"]
71
+
72
+ case action
73
+ when "inspect"
74
+ result = { "ok" => true, "revision" => revision, "rollover" => rollover }
75
+ when "prepare"
76
+ unless valid_session_id?(options[:source_session_id])
77
+ result = fail_result("invalid_source_session_id")
78
+ throw :transition_done, result
79
+ end
80
+ if rollover && %w[prepared claimed].include?(rollover["status"])
81
+ result = fail_result("rollover_in_progress", "rollover" => rollover)
82
+ throw :transition_done, result
83
+ end
84
+ rollover_id = "ROL-#{SecureRandom.uuid}"
85
+ relative_path = ".planning/handoffs/#{state.fetch("current_feature")}/#{rollover_id}.json"
86
+ absolute_path, = DevflowControlIO.resolve_control_path(root, relative_path, :handoff)
87
+ payload = {
88
+ "schema_version" => 1,
89
+ "rollover_id" => rollover_id,
90
+ "created_at" => iso_now,
91
+ "reason" => options[:reason].to_s.empty? ? "workflow_step_boundary" : options[:reason],
92
+ "source_session_id" => options[:source_session_id],
93
+ "state" => {
94
+ "revision" => revision,
95
+ "current_flow" => state["current_flow"], "current_feature" => state["current_feature"],
96
+ "current_phase" => state["current_phase"], "current_step" => state["current_step"],
97
+ "status" => state["status"], "completed_steps" => state["completed_steps"], "pending_steps" => state["pending_steps"]
98
+ },
99
+ "references" => {
100
+ "state_path" => ".planning/STATE.md",
101
+ "workflow_path" => state["workflow_config"],
102
+ "artifact_registry" => ".planning/artifacts/index.yaml"
103
+ }
104
+ }
105
+ serialized = JSON.generate(payload) + "\n"
106
+ digest = Digest::SHA256.hexdigest(serialized)
107
+ if options[:write]
108
+ DevflowControlIO.atomic_create(absolute_path, serialized)
109
+ state["rollover"] = {
110
+ "id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
111
+ "handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
112
+ "target_session_id" => nil, "expected_state_revision" => revision,
113
+ "prepared_at" => iso_now, "claimed_at" => nil
114
+ }
115
+ state["revision"] = revision + 1
116
+ write_state(state_path, state, raw)
117
+ end
118
+ result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
119
+ when "claim"
120
+ unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
121
+ result = fail_result("rollover_not_prepared", "rollover" => rollover)
122
+ throw :transition_done, result
123
+ end
124
+ unless valid_session_id?(options[:target_session_id])
125
+ result = fail_result("invalid_target_session_id")
126
+ throw :transition_done, result
127
+ end
128
+ path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
129
+ unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
130
+ result = fail_result("handoff_integrity_failure")
131
+ throw :transition_done, result
132
+ end
133
+ if options[:write]
134
+ rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
135
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
136
+ end
137
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
138
+ when "consume"
139
+ unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
140
+ result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
141
+ throw :transition_done, result
142
+ end
143
+ if options[:write]
144
+ rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
145
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
146
+ end
147
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
148
+ else
149
+ result = fail_result("unknown_action")
150
+ end
151
+ rescue DevflowControlIO::InvalidControlPath, Psych::Exception, SystemCallError, RuntimeError => error
152
+ result = fail_result("rollover_transition_failed", "message" => error.message)
153
+ ensure
154
+ DevflowControlIO.release_lock(lock)
155
+ end
156
+ end
157
+
158
+ puts JSON.pretty_generate(result)
159
+ exit(result["ok"] ? 0 : 1)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "digest"
5
6
  require "optparse"
6
7
  require "yaml"
7
8
  require_relative "control_io"
@@ -42,6 +43,7 @@ module WorkflowStateValidator
42
43
  REQUIRED_STATE_FIELDS.each do |field|
43
44
  issues << issue("error", "missing_state_field", "STATE.md is missing required field: #{field}", field: field) unless state.key?(field)
44
45
  end
46
+ validate_rollover(issues, root, state)
45
47
  return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
46
48
 
47
49
  ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
@@ -122,6 +124,40 @@ module WorkflowStateValidator
122
124
  nil
123
125
  end
124
126
 
127
+ def validate_rollover(issues, root, state)
128
+ rollover = state["rollover"]
129
+ return if rollover.nil?
130
+ unless rollover.is_a?(Hash)
131
+ issues << issue("error", "invalid_rollover", "STATE.rollover must be an object")
132
+ return
133
+ end
134
+ status = rollover["status"]
135
+ unless %w[prepared claimed consumed failed].include?(status)
136
+ issues << issue("error", "invalid_rollover_status", "STATE.rollover.status is invalid", status: status)
137
+ return
138
+ end
139
+ %w[id handoff_path handoff_sha256 source_session_id prepared_at].each do |field|
140
+ issues << issue("error", "missing_rollover_field", "STATE.rollover is missing #{field}", field: field) unless rollover[field].is_a?(String) && !rollover[field].empty?
141
+ end
142
+ return if issues.any? { |entry| entry["severity"] == "error" }
143
+ begin
144
+ handoff_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
145
+ if %w[prepared claimed consumed].include?(status) && !File.exist?(handoff_path)
146
+ issues << issue("error", "missing_rollover_handoff", "STATE.rollover references a missing immutable handoff", path: rollover["handoff_path"])
147
+ elsif File.exist?(handoff_path) && Digest::SHA256.hexdigest(File.binread(handoff_path)) != rollover["handoff_sha256"]
148
+ issues << issue("error", "rollover_handoff_hash_mismatch", "STATE.rollover handoff hash does not match immutable payload", path: rollover["handoff_path"])
149
+ end
150
+ rescue DevflowControlIO::InvalidControlPath => error
151
+ issues << issue("error", "invalid_rollover_handoff_path", error.message)
152
+ end
153
+ if rollover["handoff_sha256"].to_s !~ /\A[0-9a-f]{64}\z/
154
+ issues << issue("error", "invalid_rollover_handoff_hash", "STATE.rollover.handoff_sha256 must be SHA-256 hex")
155
+ end
156
+ if status == "claimed" && (rollover["target_session_id"].to_s.empty? || rollover["claimed_at"].to_s.empty?)
157
+ issues << issue("error", "incomplete_rollover_claim", "Claimed rollover requires target_session_id and claimed_at")
158
+ end
159
+ end
160
+
125
161
  def load_yaml(path, issues, code)
126
162
  unless File.exist?(path)
127
163
  issues << issue("error", "missing_#{code}", "YAML file does not exist", path: path)
@@ -7,6 +7,14 @@ description: "Use after integration verification and before change planning to d
7
7
 
8
8
  在代码执行前,基于已确认的需求、契约和架构约束提出独立的 `TC-*` 验收测试候选。测试意图必须独立于实现:禁止读取 `SUMMARY.md`、实现源码、git diff 或 agent 交付自述来决定测试范围。
9
9
 
10
+ ## 执行合同
11
+
12
+ 本步骤的图谱写入受 ExecutionContract v2 约束:`devflow_requirement_graph(action="propose", kind="test_case")` 与 `devflow_api_test_matrix(action="import")` 仅在 active contract 的 step 为 `acceptance-test-design` 时放行。步骤开始且无 active contract 时:
13
+
14
+ 1. 先调用 `devflow_contract(action="status")` 检查当前会话是否有 active contract;
15
+ 2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval 阶段均可,Core 校验 STATE 与图谱 revision 后由用户确认);
16
+ 3. 会话中断后 contract 由 runtime 在 `session_start` 时从 `.planning/contracts/{feature}/EC-*.json` 自动恢复;恢复时校验 state/graph revision,推进后自动失效,需重新合成。
17
+
10
18
  ## 输入
11
19
 
12
20
  按顺序读取:
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.25";
2
+ export const PRODUCT_VERSION = "0.1.27";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
@@ -10,6 +10,8 @@ export interface PlatformExtensionOptions {
10
10
  sessionRoot: string;
11
11
  ompSessionRoot: string;
12
12
  projectId: string;
13
+ /** Read the persisted role assignment so optional roles can fail closed. */
14
+ getConfiguredRole?: (role: string) => string | undefined;
13
15
  }
14
16
 
15
17
  type UsageFormat = "table" | "json" | "html";
@@ -55,6 +57,45 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
55
57
  return function devflowPlatform(pi: ExtensionAPI) {
56
58
  const { z } = pi.zod;
57
59
  pi.setLabel("Devflow Platform Runtime");
60
+ let modelBeforeVisionTurn: Parameters<ExtensionAPI["setModel"]>[0] | undefined;
61
+ let visionTurnActive = false;
62
+
63
+ pi.on("input", async (event, ctx) => {
64
+ if (event.source !== "interactive" || !event.images?.length) return;
65
+
66
+ const configuredVision = options.getConfiguredRole?.("vision")?.trim();
67
+ if (!configuredVision) {
68
+ ctx.ui.notify("检测到图片,但尚未配置 VISION 模型。请先在 /model 中配置 VISION,再重新提交。", "warning");
69
+ if (ctx.hasUI) await ctx.ui.input("VISION 未配置", "请先在 /model 中配置 VISION,完成后重新提交本次图片");
70
+ return { handled: true };
71
+ }
72
+
73
+ const visionModel = ctx.models.resolve("@vision");
74
+ if (!visionModel || !visionModel.input.includes("image")) {
75
+ ctx.ui.notify(`VISION 配置无法解析为支持图片输入的模型:${configuredVision}`, "error");
76
+ if (ctx.hasUI) await ctx.ui.input("VISION 配置无效", "请选择支持图片输入的模型后重新提交");
77
+ return { handled: true };
78
+ }
79
+
80
+ modelBeforeVisionTurn = ctx.models.current();
81
+ if (!(await pi.setModel(visionModel))) {
82
+ modelBeforeVisionTurn = undefined;
83
+ ctx.ui.notify(`无法切换到 VISION 模型:${visionModel.provider}/${visionModel.id}`, "error");
84
+ return { handled: true };
85
+ }
86
+ visionTurnActive = true;
87
+ ctx.ui.notify(`本回合已切换到 VISION:${visionModel.provider}/${visionModel.id}`, "info");
88
+ return;
89
+ });
90
+
91
+ pi.on("turn_end", async (_event, ctx) => {
92
+ if (!visionTurnActive || !modelBeforeVisionTurn) return;
93
+ const previousModel = modelBeforeVisionTurn;
94
+ modelBeforeVisionTurn = undefined;
95
+ visionTurnActive = false;
96
+ await pi.setModel(previousModel);
97
+ ctx.ui.notify(`图片回合结束,已恢复模型:${previousModel.provider}/${previousModel.id}`, "info");
98
+ });
58
99
  const usageParameters = z.object({
59
100
  source: z.enum(["project", "omp"]).default("project"),
60
101
  session: z.string().optional(),
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { OMP_RUNTIME_VERSION, PRODUCT_NAME, PRODUCT_VERSION } from "./constants.ts";
@@ -37,7 +37,9 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
37
37
  // The interactive session is the high-capability Planner. Low-cost agents
38
38
  // are dispatched only through explicit Devflow steps, one at a time by
39
39
  // default, so background work cannot silently widen a user's request.
40
- "modelRoles.default": "@slow",
40
+ // Keep ordinary interactive turns on the user's DEFAULT role. In particular,
41
+ // do not inherit SLOW just because a thinking model is configured.
42
+ "defaultThinkingLevel": "low",
41
43
  "task.batch": false,
42
44
  "task.maxConcurrency": 1,
43
45
  "task.maxRecursionDepth": 1,
@@ -61,6 +63,10 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
61
63
  "lsp.enabled": true,
62
64
  "lsp.lazy": true,
63
65
  "lsp.diagnosticsOnWrite": true,
66
+ // Devflow owns cross-session handoff through STATE.rollover. OMP's LLM-made
67
+ // handoff would create a second, unauditable source of continuation facts.
68
+ "compaction.strategy": "context-full",
69
+ "compaction.handoffSaveToDisk": false,
64
70
  };
65
71
 
66
72
  export async function createEmbeddedRuntime(project: ProjectContext, options: RuntimeOptions = {}) {
@@ -136,6 +142,7 @@ export async function createEmbeddedRuntime(project: ProjectContext, options: Ru
136
142
  sessionRoot: sessionDir,
137
143
  ompSessionRoot: path.join(sharedAgentDir, "sessions"),
138
144
  projectId: project.manifest.projectId,
145
+ getConfiguredRole: role => settings.getModelRole(role),
139
146
  }),
140
147
  ],
141
148
  hasUI: options.hasUI ?? true,
@@ -173,8 +180,39 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
173
180
  mode.showStatus(`Devflow project ${project.manifest.name} · ${project.manifest.projectId.slice(0, 8)}`);
174
181
  if (runtime.modelFallbackMessage) mode.showWarning(runtime.modelFallbackMessage);
175
182
  if (options.initialPrompt) await runtime.session.prompt(options.initialPrompt);
183
+ // Extensions cannot create a session from lifecycle hooks. The interactive
184
+ // loop, however, owns AgentSession and reaches this point only after the
185
+ // submitted turn has settled. A request is merely a wake-up signal: its
186
+ // authority remains STATE.rollover, which the extension verifies and claims
187
+ // during session_switch before it starts the next workflow turn.
188
+ const consumeControlledRollover = async (): Promise<void> => {
189
+ const requestPath = path.join(project.root, ".planning", "rollover-request.json");
190
+ let request: { schema_version?: number; rollover_id?: string; source_session_id?: string };
191
+ try {
192
+ request = JSON.parse(await readFile(requestPath, "utf8")) as typeof request;
193
+ } catch {
194
+ return;
195
+ }
196
+ if (
197
+ request.schema_version !== 1 ||
198
+ typeof request.rollover_id !== "string" ||
199
+ !/^ROL-[0-9a-f-]+$/i.test(request.rollover_id) ||
200
+ request.source_session_id !== runtime.session.sessionManager.getSessionId()
201
+ ) {
202
+ await rm(requestPath, { force: true });
203
+ mode.showWarning("已忽略无效或陈旧的 Devflow 会话轮换请求;STATE 未被修改。");
204
+ return;
205
+ }
206
+ const switched = await runtime.session.newSession();
207
+ if (!switched) {
208
+ mode.showWarning("Devflow 会话轮换被运行时取消;当前会话保持不变。请检查控制面状态后重试。");
209
+ return;
210
+ }
211
+ await rm(requestPath, { force: true });
212
+ };
176
213
  while (true) {
177
214
  const input = await mode.getUserInput();
178
215
  await omp.submitInteractiveInput(mode, runtime.session, input);
216
+ await consumeControlledRollover();
179
217
  }
180
218
  }
@@ -8,6 +8,8 @@ This repository uses an OMP-native, project-scoped software delivery runtime.
8
8
  - Inspect workflow state with `devflow_state`; do not infer state from chat history.
9
9
  - Initialize delivery with `devflow_bootstrap`.
10
10
  - Mutate checkpoints and steps only with `devflow_transition`.
11
+ - To abandon an active feature, use the runtime-confirmed `devflow_transition`
12
+ `cancel-feature` operation or `/devflow abandon`; never edit control files.
11
13
  - Never directly edit `.planning/STATE.md`, `.planning/.next-intent`, `.planning/workflows/`, or `.planning/artifacts/index.yaml`.
12
14
  - The parent session is the only workflow-state writer. Task agents return evidence or patches and never advance state.
13
15
 
@@ -1,6 +1,9 @@
1
1
  # Devflow owns this project-local OMP profile. Model ids stay in the user's
2
2
  # global config; only role storage and development policy are project-scoped.
3
3
  modelRoleStorage: project
4
+ # Ordinary turns stay on DEFAULT and use only low automatic thinking. Agent
5
+ # frontmatter may still opt into medium/high for explicitly routed work.
6
+ defaultThinkingLevel: low
4
7
 
5
8
  # Do not import competing editor/harness configuration into this dedicated Pi.
6
9
  # These are discovery-source ids, not model-provider ids.