@guanlin4924/devflow-pi 0.1.38 → 0.1.40

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 (19) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +178 -8
  3. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +157 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +131 -5
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
  18. package/src/constants.ts +1 -1
  19. package/src/runtime.ts +8 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -1,9 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
- import { mkdir, rename, writeFile } from "node:fs/promises";
3
+ import { appendFile, 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";
7
+ import {
8
+ TASK_SUBAGENT_LIFECYCLE_CHANNEL,
9
+ type SubagentLifecyclePayload,
10
+ } from "@oh-my-pi/pi-coding-agent/task";
7
11
 
8
12
 
9
13
  export type DevflowIntent = "idle" | "explain-state" | "project-init" | "requirement-draft" | "workflow-execute";
@@ -448,6 +452,7 @@ const REQUIRED_SCRIPTS = [
448
452
  "workflow-orchestrator/scripts/tdd_evidence.py",
449
453
  "workflow-orchestrator/scripts/acceptance_test_runner.py",
450
454
  "workflow-orchestrator/scripts/machine_evidence.rb",
455
+ "workflow-orchestrator/scripts/cross_track_consistency_check.rb",
451
456
  "workflow-orchestrator/scripts/execution_contract.rb",
452
457
  "workflow-code-index/scripts/code_index.py",
453
458
  "workflow-context-sync/scripts/context_sync_cursor.py",
@@ -760,12 +765,24 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
760
765
  recovery: snapshot.recovery,
761
766
  requirement_draft: requirementDraft,
762
767
  };
768
+ const parallelInstructions = snapshot.action === "run_parallel_window"
769
+ ? [
770
+ "这是并行窗口:先按 parallel_run 中的 branch 记录逐个调用 task;一个 task 只能负责一个 workflow_step,不得把多个 step 合并到一个子 Agent。",
771
+ "本窗口的 branch step(backend-tech-spec、api-call-guide、ui-spec、feature-breakdown、frontend-tech-spec、api-supplement)统一使用白名单 agent implementation-worker;不要把 workflow step 名称当作 agent 名称。",
772
+ "只有执行验证类步骤才使用 verification-runner;project-scout、requirements-auditor、contract-auditor、devflow-code-reviewer 不属于本并行窗口的默认执行 agent。",
773
+ "每个 task 的提示必须原样包含 DEVFLOW_BRANCH_ID、DEVFLOW_WORKFLOW_STEP、DEVFLOW_TRACK、DEVFLOW_ATTEMPT 和 DEVFLOW_BRANCH_REVISION,并只写该分支的白名单产物。",
774
+ "子 Agent 完成后只返回产物和验证证据;不得调用 devflow_transition,不得自行声明 checkpoint 或 join。主 Agent 负责根据生命周期结果继续调度,Core 负责最终门禁。",
775
+ "如果生命周期 status=aborted,按 failed 处理:先调用 Core 的 branch fail,再根据恢复信息调用 retry;不得把 aborted 当作成功或忽略,否则 join 会永久等待。",
776
+ ]
777
+ : [];
763
778
  return [
764
779
  "<devflow-runtime-directive>",
765
780
  "Devflow 已通过启动预检。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
766
781
  "先调用 devflow_state(action=\"inspect\") 重新确认 revision,再由 workflow-orchestrator 路由当前步骤。",
767
782
  "只按 inspector 返回的当前 step 加载所需 Skill;不得调用 devflow_transition、不得推进下一步骤、不得替用户通过人工 checkpoint。",
768
- "完成本步骤的真实产物后,报告证据、未决项和验证结果并停止。请用户审查后明确回复“批准执行”才可推进;回复修改意见或“拒绝执行”则不得推进。",
783
+ ...(parallelInstructions.length > 0 ? parallelInstructions : [
784
+ "完成本步骤的真实产物后,报告证据、未决项和验证结果并停止。请用户审查后明确回复“批准执行”才可推进;回复修改意见或“拒绝执行”则不得推进。",
785
+ ]),
769
786
  "当前步骤信封:",
770
787
  JSON.stringify(envelope, null, 2),
771
788
  "</devflow-runtime-directive>",
@@ -820,6 +837,15 @@ function inputPaths(input: unknown, cwd: string): string[] {
820
837
  .filter((value): value is string => value !== null);
821
838
  }
822
839
 
840
+ function parallelBranchContext(input: unknown): { branch_id: string; attempt: number; branch_revision: number } | null {
841
+ const text = typeof input === "string" ? input : JSON.stringify(input ?? {});
842
+ const branch = text.match(/DEVFLOW_BRANCH_ID\s*[:=]\s*([^\s,}"']+)/)?.[1];
843
+ const attempt = text.match(/DEVFLOW_ATTEMPT\s*[:=]\s*(\d+)/)?.[1];
844
+ const revision = text.match(/DEVFLOW_BRANCH_REVISION\s*[:=]\s*(\d+)/)?.[1];
845
+ if (!branch || attempt === undefined || revision === undefined) return null;
846
+ return { branch_id: branch, attempt: Number(attempt), branch_revision: Number(revision) };
847
+ }
848
+
823
849
  function isControlPath(filePath: string): boolean {
824
850
  return CONTROL_PATHS.includes(filePath) || CONTROL_PREFIXES.some(prefix => filePath.startsWith(prefix));
825
851
  }
@@ -864,6 +890,31 @@ export default function devflowCore(pi: ExtensionAPI) {
864
890
  let controlState: SessionControlState = initialControlState();
865
891
  let activeContract: ActiveExecutionContract | undefined;
866
892
  let contractAgentCalls = 0;
893
+ let projectRoot: string | undefined;
894
+
895
+ async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
896
+ if (!cwd || !activeContract) return;
897
+ const feature = activeContract.feature;
898
+ const step = activeContract.step;
899
+ if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
900
+ const directory = path.join(cwd, ".planning", "evidence", feature);
901
+ const attempt = typeof extra.attempt === "number" && Number.isInteger(extra.attempt) && extra.attempt >= 0
902
+ ? extra.attempt
903
+ : undefined;
904
+ const destination = path.join(directory, `${step}${attempt === undefined ? "" : `.attempt-${attempt}`}.engine-events.jsonl`);
905
+ await mkdir(directory, { recursive: true });
906
+ await appendFile(destination, `${JSON.stringify({
907
+ schema_version: 1,
908
+ timestamp: new Date().toISOString(),
909
+ session_id: sessionId,
910
+ feature,
911
+ step,
912
+ event,
913
+ ...(attempt === undefined ? {} : { attempt, branch_id: extra.branch_id }),
914
+ collector: "devflow-runtime/harness",
915
+ ...extra,
916
+ })}\n`, "utf8");
917
+ }
867
918
 
868
919
  function controlGuide(state: SessionControlState): string[] {
869
920
  const title = "Devflow 操作指引";
@@ -1302,6 +1353,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1302
1353
  }
1303
1354
 
1304
1355
  let activeWorkflowTurn = false;
1356
+ let parallelRunActive = false;
1357
+ let parallelContinuationQueued = false;
1358
+ const pendingParallelLifecycle = new Map<string, string>();
1359
+ const parallelTaskContexts = new Map<string, { branch_id: string; attempt: number; branch_revision: number }>();
1305
1360
  let pendingProjectInitialization: ProjectInitializationRequest | undefined;
1306
1361
  let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
1307
1362
 
@@ -1330,14 +1385,87 @@ export default function devflowCore(pi: ExtensionAPI) {
1330
1385
  pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
1331
1386
  }
1332
1387
 
1388
+ function parallelBranches(snapshot: Record<string, unknown>): Array<{ workflow_step: string; track: string }> {
1389
+ const window = snapshot.parallel_window;
1390
+ if (!window || typeof window !== "object") return [];
1391
+ const branches = (window as Record<string, unknown>).branches;
1392
+ if (!branches || typeof branches !== "object") return [];
1393
+ return Object.entries(branches as Record<string, unknown>).flatMap(([track, steps]) => {
1394
+ if (!Array.isArray(steps)) return [];
1395
+ return steps.flatMap(step => {
1396
+ const workflowStep = typeof step === "string"
1397
+ ? step
1398
+ : step && typeof step === "object" ? String((step as Record<string, unknown>).id ?? "") : "";
1399
+ return workflowStep ? [{ workflow_step: workflowStep, track }] : [];
1400
+ });
1401
+ });
1402
+ }
1403
+
1404
+ async function prepareParallelRun(cwd: string, snapshot: Record<string, unknown>): Promise<Record<string, unknown>> {
1405
+ if (snapshot.action !== "run_parallel_window") return snapshot;
1406
+ const feature = String(snapshot.current_feature ?? "");
1407
+ const window = snapshot.parallel_window as Record<string, unknown> | undefined;
1408
+ const workflowWindow = String(window?.id ?? "");
1409
+ const existing = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/parallel_run.rb", [
1410
+ "--action", "inspect", "--feature", feature, "--json",
1411
+ ]));
1412
+ if (existing?.ok === true && existing.run) {
1413
+ parallelRunActive = true;
1414
+ return { ...snapshot, parallel_run: existing.run };
1415
+ }
1416
+ const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
1417
+ if (!feature || !workflowWindow || revision === undefined) throw new Error("parallel window snapshot is incomplete");
1418
+ const branches = parallelBranches(snapshot);
1419
+ if (branches.length === 0) throw new Error("parallel window contains no branch steps");
1420
+ const prepared = await runRuby(cwd, "workflow-orchestrator/scripts/parallel_run.rb", [
1421
+ "--action", "prepare", "--feature", feature, "--workflow-window", workflowWindow,
1422
+ "--state-revision", String(revision), "--json",
1423
+ ...branches.flatMap(branch => ["--branch", `${branch.workflow_step}:${branch.track}`]),
1424
+ ]);
1425
+ const payload = parseJsonResult(prepared);
1426
+ if (!controlPlaneSucceeded(prepared) || !payload?.run) {
1427
+ throw new Error(prepared.stderr || prepared.stdout || "parallel run prepare failed");
1428
+ }
1429
+ parallelRunActive = true;
1430
+ return { ...snapshot, parallel_run: payload.run };
1431
+ }
1432
+
1333
1433
  async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
1434
+ if (snapshot.action !== "run_parallel_window") {
1435
+ parallelRunActive = false;
1436
+ parallelContinuationQueued = false;
1437
+ }
1438
+ const preparedSnapshot = await prepareParallelRun(projectRoot ?? process.cwd(), snapshot);
1334
1439
  activeWorkflowTurn = true;
1335
- await pi.sendUserMessage(workflowDirective(snapshot, source, requirementDraft));
1440
+ await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
1336
1441
  }
1337
1442
 
1443
+ function scheduleParallelContinuation() {
1444
+ if (parallelContinuationQueued || pendingParallelLifecycle.size === 0) return;
1445
+ parallelContinuationQueued = true;
1446
+ const events = [...pendingParallelLifecycle.entries()].map(([id, status]) => `${id}:${status}`);
1447
+ pendingParallelLifecycle.clear();
1448
+ pi.sendMessage({
1449
+ customType: "devflow-parallel-lifecycle",
1450
+ content: `并行子 Agent 生命周期事件已到达(${events.join(", ")})。请重新 inspect 整个 parallel run,逐一处理所有已完成/失败分支,必要时调用 Core 的 branch complete/fail/retry 动作,然后继续派发仍满足前置依赖的分支;不得直接 join。`,
1451
+ display: false,
1452
+ details: { events },
1453
+ }, { triggerTurn: true, deliverAs: "nextTurn" });
1454
+ }
1455
+
1456
+ pi.events.on(TASK_SUBAGENT_LIFECYCLE_CHANNEL, data => {
1457
+ if (!parallelRunActive || !data || typeof data !== "object") return;
1458
+ const event = data as Partial<SubagentLifecyclePayload>;
1459
+ if (typeof event.id !== "string" || !["started", "completed", "failed", "aborted"].includes(String(event.status))) return;
1460
+ if (event.status === "started") return;
1461
+ pendingParallelLifecycle.set(event.id, String(event.status));
1462
+ scheduleParallelContinuation();
1463
+ });
1464
+
1338
1465
  pi.setLabel("Devflow Project Runtime");
1339
1466
 
1340
1467
  pi.on("session_start", async (_event, ctx) => {
1468
+ projectRoot = ctx.cwd;
1341
1469
  sessionId = randomUUID();
1342
1470
  const result = await runHook(ctx.cwd, "start.py", {
1343
1471
  session_id: sessionId,
@@ -1452,13 +1580,26 @@ export default function devflowCore(pi: ExtensionAPI) {
1452
1580
  await triggerWorkflow(next.snapshot, "approval");
1453
1581
  });
1454
1582
 
1455
- pi.on("before_agent_start", async event => ({
1456
- systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY],
1457
- }));
1583
+ pi.on("before_agent_start", async event => {
1584
+ if (activeWorkflowTurn && activeContract) {
1585
+ await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
1586
+ await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
1587
+ }
1588
+ return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
1589
+ });
1458
1590
 
1459
1591
  pi.on("agent_end", async (event, ctx) => {
1460
1592
  if (!activeWorkflowTurn || event.willContinue) return;
1461
1593
  activeWorkflowTurn = false;
1594
+ if (parallelRunActive) {
1595
+ parallelContinuationQueued = false;
1596
+ setControlState(ctx, executingState("并行窗口运行中,等待子 Agent 生命周期事件"));
1597
+ // A second detached task may finish while the continuation is running.
1598
+ // Its event is retained and gets a follow-up continuation instead of
1599
+ // being dropped behind the current queue marker.
1600
+ scheduleParallelContinuation();
1601
+ return;
1602
+ }
1462
1603
  if (controlState.phase === "executing") {
1463
1604
  setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
1464
1605
  ctx.ui.notify("当前步骤已结束。请审查输出:明确回复“批准执行”推进,或回复修改意见/“拒绝执行”保持当前步骤。", "info");
@@ -1543,7 +1684,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1543
1684
  "--json",
1544
1685
  "--expected-revision",
1545
1686
  String(revision),
1546
- ...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
1687
+ ...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
1547
1688
  ]);
1548
1689
  if (!result.ok) {
1549
1690
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
@@ -1619,6 +1760,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1619
1760
 
1620
1761
  pi.on("tool_call", async (event, ctx) => {
1621
1762
  const toolName = String(event.toolName).toLowerCase();
1763
+ if (parallelRunActive && toolName === "task") {
1764
+ const branchContext = parallelBranchContext(event.input);
1765
+ if (branchContext) parallelTaskContexts.set(event.toolCallId, branchContext);
1766
+ }
1622
1767
  if (activeContract && controlState.phase === "executing" && !toolName.startsWith("devflow_")) {
1623
1768
  if (!activeContract.allowedTools.includes(toolName)) {
1624
1769
  return { block: true, reason: `Active contract ${activeContract.contractId} does not allow tool: ${toolName}` };
@@ -1691,6 +1836,18 @@ export default function devflowCore(pi: ExtensionAPI) {
1691
1836
  "Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
1692
1837
  };
1693
1838
  }
1839
+ if (isUserConfirmedStepTransition && activeContract) {
1840
+ const evidence = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/machine_evidence.rb", [
1841
+ "--feature", activeContract.feature, "--step", activeContract.step, "--write", "--json",
1842
+ ]);
1843
+ const evidencePayload = parseJsonResult(evidence);
1844
+ if (!evidence.ok || !evidencePayload || evidencePayload.complete !== true) {
1845
+ return {
1846
+ block: true,
1847
+ reason: `Devflow machine evidence is incomplete; collect independent runtime evidence before completing ${activeContract.step}.`,
1848
+ };
1849
+ }
1850
+ }
1694
1851
  if (
1695
1852
  !toolName.startsWith("devflow_") &&
1696
1853
  ["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
@@ -1767,6 +1924,15 @@ export default function devflowCore(pi: ExtensionAPI) {
1767
1924
 
1768
1925
  pi.on("tool_result", async (event, ctx) => {
1769
1926
  if (event.isError) return;
1927
+ if (activeWorkflowTurn && activeContract) {
1928
+ const taskContext = parallelTaskContexts.get(event.toolCallId);
1929
+ await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
1930
+ engine: event.toolName,
1931
+ ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
1932
+ ...(taskContext ?? {}),
1933
+ });
1934
+ if (taskContext) parallelTaskContexts.delete(event.toolCallId);
1935
+ }
1770
1936
  await runHook(ctx.cwd, "post_tool.py", {
1771
1937
  session_id: sessionId,
1772
1938
  cwd: ctx.cwd,
@@ -1867,7 +2033,11 @@ export default function devflowCore(pi: ExtensionAPI) {
1867
2033
  stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
1868
2034
  });
1869
2035
  }
1870
- args.push("--write", "--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
2036
+ args.push("--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
2037
+ // `write` is an explicit dry-run switch. Omit it only when the caller
2038
+ // explicitly asks for a preview; the normal approved proposal remains
2039
+ // a Core-owned write.
2040
+ if (params.write !== false) args.push("--write");
1871
2041
  if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
1872
2042
  }
1873
2043
  if (params.action === "confirm") {
@@ -12,6 +12,7 @@ require_relative "control_io"
12
12
  require_relative "validate_state"
13
13
  require_relative "phase_ownership"
14
14
  require_relative "requirement_graph"
15
+ require_relative "gate_runtime"
15
16
 
16
17
  options = {
17
18
  state: nil,
@@ -24,7 +25,13 @@ options = {
24
25
  cancel: false,
25
26
  cancel_reason: nil,
26
27
  expected_revision: nil,
27
- json: false
28
+ json: false,
29
+ runtime_confirmed: false,
30
+ parallel_action: nil,
31
+ parallel_branch_id: nil,
32
+ parallel_attempt: nil,
33
+ parallel_branch_revision: nil,
34
+ parallel_evidence: {}
28
35
  }
29
36
 
30
37
  OptionParser.new do |opts|
@@ -40,6 +47,13 @@ OptionParser.new do |opts|
40
47
  opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
41
48
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
42
49
  opts.on("--json", "Print JSON output") { options[:json] = true }
50
+ opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
51
+ opts.on("--parallel-action ACTION", %w[start complete fail retry join]) { |value| options[:parallel_action] = value }
52
+ opts.on("--parallel-branch-id ID", "Branch id for a parallel transition") { |value| options[:parallel_branch_id] = value }
53
+ opts.on("--parallel-task-id ID", "OMP task id to bind to a branch") { |value| options[:parallel_task_id] = value }
54
+ opts.on("--parallel-attempt N", Integer) { |value| options[:parallel_attempt] = value }
55
+ opts.on("--parallel-branch-revision N", Integer) { |value| options[:parallel_branch_revision] = value }
56
+ opts.on("--parallel-evidence JSON") { |value| options[:parallel_evidence] = JSON.parse(value) }
43
57
  end.parse!
44
58
 
45
59
  root = Dir.pwd
@@ -64,6 +78,63 @@ def load_workflow(root, state)
64
78
  [relative, load_yaml_file(path)]
65
79
  end
66
80
 
81
+ def gate_contract_enabled?(workflow)
82
+ workflow.dig("governance", "gate_contract_version").to_i >= 3
83
+ end
84
+
85
+ # Build the runtime view from the existing workflow declaration. This keeps
86
+ # governance.step_checkers as the single checker namespace and avoids creating
87
+ # a second hand-maintained per-step transition table.
88
+ def transition_gate_contract(workflow, step)
89
+ checkpoints = workflow["checkpoints"] || {}
90
+ checkpoint_id = step["checkpoint"]
91
+ checkpoint = checkpoint_id && checkpoints[checkpoint_id]
92
+ track_names = Array(workflow["parallel_windows"]).flat_map do |window|
93
+ next [] unless window.is_a?(Hash) && window["branches"].is_a?(Hash)
94
+ window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
95
+ end
96
+ allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
97
+ reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
98
+ next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
99
+ entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
100
+ end
101
+ {
102
+ "gate_id" => "#{step.fetch("id")}-gate-v1",
103
+ "step_id" => step.fetch("id"),
104
+ "topology" => {
105
+ "allowed_tracks" => allowed_tracks,
106
+ "allowed_predecessors" => Array(step["depends_on"]),
107
+ "allowed_reentry" => reentries,
108
+ "checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
109
+ },
110
+ "entry" => { "required_inputs" => Array(step["inputs"]) },
111
+ "outputs" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
112
+ "quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
113
+ "concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
114
+ "recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
115
+ }
116
+ end
117
+
118
+ def transition_gate_preflight(workflow, state, step, current_revision, checkpoint_authorized = false, transition_kind = nil)
119
+ return nil unless gate_contract_enabled?(workflow)
120
+
121
+ contract = transition_gate_contract(workflow, step)
122
+ track = state["active_track"].to_s
123
+ track = "main" unless %w[A B].include?(track)
124
+ GateRuntime.evaluate(
125
+ contract: contract,
126
+ workflow_state: state,
127
+ current_revision: current_revision,
128
+ expected_revision: current_revision,
129
+ track: track,
130
+ source: "advance_state",
131
+ root: Dir.pwd,
132
+ check_outputs: step["id"].to_s == state["current_step"].to_s,
133
+ transition_kind: transition_kind,
134
+ checkpoint_authorized: checkpoint_authorized
135
+ )
136
+ end
137
+
67
138
  def interpolate_path(path, state)
68
139
  path
69
140
  .gsub("{feature}", state.fetch("current_feature", "").to_s)
@@ -349,6 +420,63 @@ def track_boundary?(state, pending_ids)
349
420
  (pending_ids & track_allowed_steps(track)).empty? && pending_ids.any?
350
421
  end
351
422
 
423
+ def parallel_window_for(workflow, completed_step_id)
424
+ Array(workflow["parallel_windows"]).find do |window|
425
+ window.is_a?(Hash) && window["after_step"].to_s == completed_step_id.to_s
426
+ end
427
+ end
428
+
429
+ def parallel_branch_for(root, state, branch_id)
430
+ record = ParallelRun.load(root, state.fetch("current_feature"))
431
+ branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
432
+ [record, branch]
433
+ end
434
+
435
+ def advance_parallel_branch(root, state, options, lock = nil)
436
+ branch_id = options[:parallel_branch_id]
437
+ raise ArgumentError, "parallel branch id is required" if branch_id.to_s.empty?
438
+ record, branch = parallel_branch_for(root, state, branch_id)
439
+ raise ArgumentError, "parallel run not found" unless record
440
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
441
+ expected = Integer(record.fetch("run_revision", 0))
442
+ case options[:parallel_action]
443
+ when "start"
444
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "running", task_id: options[:parallel_task_id], lock: lock)
445
+ when "complete"
446
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "succeeded", evidence_path: options[:parallel_evidence]["evidence_path"], output_fingerprint: options[:parallel_evidence]["output_fingerprint"], lock: lock)
447
+ when "fail"
448
+ # The error is diagnostic metadata only; it is never used as evidence or
449
+ # as an authorization for branch completion.
450
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "failed", error: options[:parallel_evidence]["error"], lock: lock)
451
+ when "retry"
452
+ ParallelRun.retry_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, lock: lock)
453
+ else
454
+ raise ArgumentError, "unsupported parallel branch action"
455
+ end
456
+ end
457
+
458
+ def join_parallel_window(root, state, workflow, current_revision)
459
+ record = ParallelRun.load(root, state.fetch("current_feature"))
460
+ raise ArgumentError, "parallel run not found" unless record
461
+ branches = Array(record["branches"])
462
+ incomplete = branches.reject { |branch| branch["status"].to_s == "succeeded" }
463
+ raise ArgumentError, "parallel join requires every branch to succeed" unless branches.any? && incomplete.empty?
464
+
465
+ branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
466
+ state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
467
+ state["pending_steps"] = Array(state["pending_steps"]) - branch_steps
468
+ state["last_completed_step"] = state["current_step"]
469
+ state.delete("parallel_run_ref")
470
+ record["status"] = "completed"
471
+ record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
472
+ record["updated_at"] = Time.now.utc.iso8601
473
+ state["current_step"] = workflow.fetch("parallel_windows").find { |window| window["id"].to_s == record["workflow_window"].to_s }.fetch("join_step")
474
+ state["status"] = "in_progress"
475
+ state["state_updated_at"] = Date.today.to_s
476
+ state["revision"] = current_revision + 1
477
+ [state, record, "Parallel window joined; branch steps merged and parallel_run_ref cleared."]
478
+ end
479
+
352
480
  def fail_result(reason, details = {})
353
481
  { "ok" => false, "reason" => reason, "details" => details }
354
482
  end
@@ -397,6 +525,40 @@ rescue JSON::ParserError => error
397
525
  { "ok" => false, "errors" => ["API test schema checker returned invalid JSON: #{error.message}"] }
398
526
  end
399
527
 
528
+ def cross_track_checker_enabled?(workflow, step_id)
529
+ Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("cross-track-consistency-check")
530
+ end
531
+
532
+ def cross_track_consistency_check(root, feature)
533
+ script = File.join(__dir__, "cross_track_consistency_check.rb")
534
+ stdout, stderr, status = Open3.capture3("ruby", script, "--feature", feature, "--json", chdir: root)
535
+ payload = JSON.parse(stdout)
536
+ return payload if status.success? && payload.is_a?(Hash)
537
+
538
+ { "ok" => false, "errors" => [stderr.strip.empty? ? "cross-track checker returned invalid output" : stderr.strip] }
539
+ rescue JSON::ParserError => error
540
+ { "ok" => false, "errors" => ["cross-track checker returned invalid JSON: #{error.message}"] }
541
+ end
542
+
543
+ def branch_machine_evidence(root, state, branch)
544
+ script = File.join(__dir__, "machine_evidence.rb")
545
+ step = branch.fetch("workflow_step").to_s
546
+ attempt = Integer(branch.fetch("attempt", 0))
547
+ revision = Integer(branch.fetch("branch_revision", 0))
548
+ stdout, stderr, status = Open3.capture3(
549
+ "ruby", script, "--feature", state.fetch("current_feature"), "--step", step,
550
+ "--parallel-branch-id", branch.fetch("branch_id").to_s,
551
+ "--parallel-attempt", attempt.to_s, "--parallel-branch-revision", revision.to_s,
552
+ "--lock-held", "--write", "--json", chdir: root
553
+ )
554
+ payload = JSON.parse(stdout)
555
+ return payload if status.success? && payload.is_a?(Hash) && payload["ok"] == true
556
+
557
+ { "ok" => false, "errors" => [stderr.strip.empty? ? "machine evidence returned invalid output" : stderr.strip] }
558
+ rescue JSON::ParserError => error
559
+ { "ok" => false, "errors" => ["machine evidence returned invalid JSON: #{error.message}"] }
560
+ end
561
+
400
562
  def test_suite_freeze_attestation_path(root, feature)
401
563
  DevflowControlIO.resolve_control_path(root, ".planning/features/#{feature}/test-suite-freeze.json", :artifact)
402
564
  end
@@ -505,6 +667,7 @@ result =
505
667
  next_intent_written = nil
506
668
  actions = []
507
669
  action_description = nil
670
+ parallel_run_to_write = nil
508
671
  next_intent_requested = false
509
672
  current_revision = Integer(state.fetch("revision", 0))
510
673
 
@@ -516,6 +679,118 @@ result =
516
679
  )
517
680
  end
518
681
 
682
+ # Branch completion evidence is always generated by Core from the
683
+ # persisted branch record and current artifacts. Ignore any caller-built
684
+ # binding so an agent cannot self-attest a different attempt/revision.
685
+ if result.nil? && options[:parallel_action] == "complete" && options[:write]
686
+ _run, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
687
+ if branch.nil?
688
+ result = fail_result("parallel_branch_not_found")
689
+ else
690
+ generated = branch_machine_evidence(root, state, branch)
691
+ if generated["ok"] == true
692
+ binding = generated.fetch("parallel_binding")
693
+ options[:parallel_evidence] = {
694
+ "evidence_path" => generated.fetch("path"),
695
+ "branch_id" => binding.fetch("branch_id"),
696
+ "attempt" => binding.fetch("attempt"),
697
+ "branch_revision" => binding.fetch("branch_revision"),
698
+ "state_revision" => binding.fetch("state_revision")
699
+ }
700
+ else
701
+ result = fail_result("parallel_machine_evidence_failed", "evidence" => generated)
702
+ end
703
+ end
704
+ end
705
+
706
+ # GateRuntime is a read-only boundary check. It runs while the project
707
+ # lock is held and before any candidate state mutation; the existing
708
+ # validator and advance_state CAS remain the final write guard.
709
+ if result.nil? && gate_contract_enabled?(workflow) &&
710
+ !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
711
+ (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
712
+ target_step_id = if options[:parallel_action] && options[:parallel_action] != "join"
713
+ _record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
714
+ branch && branch["workflow_step"]
715
+ elsif options[:complete_step]
716
+ options[:complete_step]
717
+ elsif options[:confirm_checkpoint]
718
+ state["current_step"]
719
+ else
720
+ state["current_step"]
721
+ end
722
+ target_step = step_by_id[target_step_id]
723
+ if target_step.nil?
724
+ result = fail_result("gate_target_step_unknown", "step" => target_step_id)
725
+ elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
726
+ result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
727
+ else
728
+ transition_kind = if options[:parallel_action]
729
+ { "start" => "parallel_branch_start", "complete" => "parallel_branch_complete", "fail" => "parallel_branch_fail", "retry" => "parallel_branch_retry", "join" => "parallel_join" }.fetch(options[:parallel_action])
730
+ elsif options[:confirm_checkpoint]
731
+ "confirm_checkpoint"
732
+ elsif options[:enter_failure_branch]
733
+ "enter_failure_branch"
734
+ elsif options[:exit_failure_branch]
735
+ "exit_failure_branch"
736
+ end
737
+ gate_result = GateRuntime.evaluate(
738
+ contract: transition_gate_contract(workflow, target_step),
739
+ workflow_state: state,
740
+ current_revision: current_revision,
741
+ expected_revision: current_revision,
742
+ track: if options[:parallel_action] && options[:parallel_action] != "join"
743
+ _record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
744
+ branch ? branch["track"].to_s.sub(/\Atrack_/, "").upcase : (active_track(state) || "main")
745
+ else
746
+ active_track(state) || "main"
747
+ end,
748
+ branch_id: options[:parallel_branch_id],
749
+ attempt: options[:parallel_attempt],
750
+ branch_revision: options[:parallel_branch_revision],
751
+ evidence: options[:parallel_evidence],
752
+ source: "advance_state",
753
+ root: root,
754
+ check_outputs: target_step["id"].to_s == state["current_step"].to_s,
755
+ transition_kind: transition_kind,
756
+ checkpoint_authorized: options[:runtime_confirmed]
757
+ )
758
+ if gate_result && gate_result["verdict"] != "allow"
759
+ result = fail_result("gate_boundary_rejected", "gate" => gate_result)
760
+ end
761
+ end
762
+ end
763
+
764
+ if result.nil? && options[:parallel_action]
765
+ if !options[:write]
766
+ result = fail_result("parallel_transition_requires_write")
767
+ elsif options[:parallel_action] == "join"
768
+ begin
769
+ if cross_track_checker_enabled?(workflow, "integration-verify")
770
+ checker = cross_track_consistency_check(root, state.fetch("current_feature"))
771
+ unless checker["ok"] == true
772
+ result = fail_result("cross_track_consistency_failed", "checker" => checker)
773
+ end
774
+ end
775
+ if result.nil?
776
+ state, parallel_run_to_write, action_description = join_parallel_window(root, state, workflow, current_revision)
777
+ changed = true
778
+ actions << "parallel_window_joined"
779
+ end
780
+ rescue ArgumentError, KeyError, TypeError => error
781
+ result = fail_result("parallel_join_failed", "message" => error.message)
782
+ end
783
+ else
784
+ begin
785
+ advance_parallel_branch(root, state, options, project_lock)
786
+ actions << "parallel_branch_#{options[:parallel_action]}"
787
+ action_description = "Parallel branch #{options[:parallel_action]}: #{options[:parallel_branch_id]}"
788
+ rescue ArgumentError, KeyError, TypeError => error
789
+ result = fail_result("parallel_branch_transition_failed", "message" => error.message)
790
+ end
791
+ end
792
+ end
793
+
519
794
  if result.nil? && options[:checkpoint_decision]
520
795
  state["checkpoint_decision"] = options[:checkpoint_decision]
521
796
  changed = true
@@ -582,6 +857,12 @@ result =
582
857
  state["checkpoint"] = step["checkpoint"]
583
858
  state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
584
859
  action_description = "Step completed: #{step["label"]} (#{step_id}). Now waiting at #{step["checkpoint"]}."
860
+ elsif (parallel_window = parallel_window_for(workflow, step_id))
861
+ state["current_step"] = parallel_window.fetch("join_step")
862
+ state["status"] = "in_progress"
863
+ state.delete("checkpoint")
864
+ state.delete("checkpoint_label")
865
+ action_description = "Step completed: #{step["label"]} (#{step_id}). Entered parallel window #{parallel_window["id"]}; awaiting Core prepare."
585
866
  elsif track_boundary?(state, pending)
586
867
  state["current_step"] = step_id
587
868
  state["status"] = "blocked"
@@ -824,6 +1105,7 @@ result =
824
1105
  sync_artifact_registry(root, workflow, state, registry: candidate_registry)
825
1106
  artifact_by_path = load_artifact_registry(root, workflow)
826
1107
  write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
1108
+ ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
827
1109
  # STATE.md is project-global. Once its top-level workflow is
828
1110
  # terminal, retain the historical owner but release its active
829
1111
  # reservation so the next feature can claim a distinct phase.