@guanlin4924/devflow-pi 0.1.39 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -4,6 +4,10 @@ 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
  }
@@ -872,7 +898,10 @@ export default function devflowCore(pi: ExtensionAPI) {
872
898
  const step = activeContract.step;
873
899
  if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
874
900
  const directory = path.join(cwd, ".planning", "evidence", feature);
875
- const destination = path.join(directory, `${step}.engine-events.jsonl`);
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`);
876
905
  await mkdir(directory, { recursive: true });
877
906
  await appendFile(destination, `${JSON.stringify({
878
907
  schema_version: 1,
@@ -881,6 +910,7 @@ export default function devflowCore(pi: ExtensionAPI) {
881
910
  feature,
882
911
  step,
883
912
  event,
913
+ ...(attempt === undefined ? {} : { attempt, branch_id: extra.branch_id }),
884
914
  collector: "devflow-runtime/harness",
885
915
  ...extra,
886
916
  })}\n`, "utf8");
@@ -1323,6 +1353,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1323
1353
  }
1324
1354
 
1325
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 }>();
1326
1360
  let pendingProjectInitialization: ProjectInitializationRequest | undefined;
1327
1361
  let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
1328
1362
 
@@ -1351,11 +1385,83 @@ export default function devflowCore(pi: ExtensionAPI) {
1351
1385
  pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
1352
1386
  }
1353
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
+
1354
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);
1355
1439
  activeWorkflowTurn = true;
1356
- await pi.sendUserMessage(workflowDirective(snapshot, source, requirementDraft));
1440
+ await pi.sendUserMessage(workflowDirective(preparedSnapshot, source, requirementDraft));
1441
+ }
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" });
1357
1454
  }
1358
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
+
1359
1465
  pi.setLabel("Devflow Project Runtime");
1360
1466
 
1361
1467
  pi.on("session_start", async (_event, ctx) => {
@@ -1485,6 +1591,15 @@ export default function devflowCore(pi: ExtensionAPI) {
1485
1591
  pi.on("agent_end", async (event, ctx) => {
1486
1592
  if (!activeWorkflowTurn || event.willContinue) return;
1487
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
+ }
1488
1603
  if (controlState.phase === "executing") {
1489
1604
  setControlState(ctx, waitingUserState("等待用户审查步骤产物并明确决定"));
1490
1605
  ctx.ui.notify("当前步骤已结束。请审查输出:明确回复“批准执行”推进,或回复修改意见/“拒绝执行”保持当前步骤。", "info");
@@ -1645,6 +1760,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1645
1760
 
1646
1761
  pi.on("tool_call", async (event, ctx) => {
1647
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
+ }
1648
1767
  if (activeContract && controlState.phase === "executing" && !toolName.startsWith("devflow_")) {
1649
1768
  if (!activeContract.allowedTools.includes(toolName)) {
1650
1769
  return { block: true, reason: `Active contract ${activeContract.contractId} does not allow tool: ${toolName}` };
@@ -1806,10 +1925,13 @@ export default function devflowCore(pi: ExtensionAPI) {
1806
1925
  pi.on("tool_result", async (event, ctx) => {
1807
1926
  if (event.isError) return;
1808
1927
  if (activeWorkflowTurn && activeContract) {
1928
+ const taskContext = parallelTaskContexts.get(event.toolCallId);
1809
1929
  await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
1810
1930
  engine: event.toolName,
1811
1931
  ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
1932
+ ...(taskContext ?? {}),
1812
1933
  });
1934
+ if (taskContext) parallelTaskContexts.delete(event.toolCallId);
1813
1935
  }
1814
1936
  await runHook(ctx.cwd, "post_tool.py", {
1815
1937
  session_id: sessionId,
@@ -26,7 +26,12 @@ options = {
26
26
  cancel_reason: nil,
27
27
  expected_revision: nil,
28
28
  json: false,
29
- runtime_confirmed: 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: {}
30
35
  }
31
36
 
32
37
  OptionParser.new do |opts|
@@ -43,6 +48,12 @@ OptionParser.new do |opts|
43
48
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
44
49
  opts.on("--json", "Print JSON output") { options[:json] = true }
45
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) }
46
57
  end.parse!
47
58
 
48
59
  root = Dir.pwd
@@ -409,6 +420,63 @@ def track_boundary?(state, pending_ids)
409
420
  (pending_ids & track_allowed_steps(track)).empty? && pending_ids.any?
410
421
  end
411
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
+
412
480
  def fail_result(reason, details = {})
413
481
  { "ok" => false, "reason" => reason, "details" => details }
414
482
  end
@@ -457,6 +525,40 @@ rescue JSON::ParserError => error
457
525
  { "ok" => false, "errors" => ["API test schema checker returned invalid JSON: #{error.message}"] }
458
526
  end
459
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
+
460
562
  def test_suite_freeze_attestation_path(root, feature)
461
563
  DevflowControlIO.resolve_control_path(root, ".planning/features/#{feature}/test-suite-freeze.json", :artifact)
462
564
  end
@@ -565,6 +667,7 @@ result =
565
667
  next_intent_written = nil
566
668
  actions = []
567
669
  action_description = nil
670
+ parallel_run_to_write = nil
568
671
  next_intent_requested = false
569
672
  current_revision = Integer(state.fetch("revision", 0))
570
673
 
@@ -576,13 +679,40 @@ result =
576
679
  )
577
680
  end
578
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
+
579
706
  # GateRuntime is a read-only boundary check. It runs while the project
580
707
  # lock is held and before any candidate state mutation; the existing
581
708
  # validator and advance_state CAS remain the final write guard.
582
709
  if result.nil? && gate_contract_enabled?(workflow) &&
583
710
  !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
584
- (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch])
585
- target_step_id = if options[:complete_step]
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]
586
716
  options[:complete_step]
587
717
  elsif options[:confirm_checkpoint]
588
718
  state["current_step"]
@@ -595,20 +725,72 @@ result =
595
725
  elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
596
726
  result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
597
727
  else
598
- transition_kind = if options[:confirm_checkpoint]
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]
599
731
  "confirm_checkpoint"
600
732
  elsif options[:enter_failure_branch]
601
733
  "enter_failure_branch"
602
734
  elsif options[:exit_failure_branch]
603
735
  "exit_failure_branch"
604
736
  end
605
- gate_result = transition_gate_preflight(workflow, state, target_step, current_revision, options[:runtime_confirmed], transition_kind)
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
+ )
606
758
  if gate_result && gate_result["verdict"] != "allow"
607
759
  result = fail_result("gate_boundary_rejected", "gate" => gate_result)
608
760
  end
609
761
  end
610
762
  end
611
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
+
612
794
  if result.nil? && options[:checkpoint_decision]
613
795
  state["checkpoint_decision"] = options[:checkpoint_decision]
614
796
  changed = true
@@ -675,6 +857,12 @@ result =
675
857
  state["checkpoint"] = step["checkpoint"]
676
858
  state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
677
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."
678
866
  elsif track_boundary?(state, pending)
679
867
  state["current_step"] = step_id
680
868
  state["status"] = "blocked"
@@ -917,6 +1105,7 @@ result =
917
1105
  sync_artifact_registry(root, workflow, state, registry: candidate_registry)
918
1106
  artifact_by_path = load_artifact_registry(root, workflow)
919
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
920
1109
  # STATE.md is project-global. Once its top-level workflow is
921
1110
  # terminal, retain the historical owner but release its active
922
1111
  # reservation so the next feature can claim a distinct phase.
@@ -7,6 +7,7 @@ module CheckerRegistry
7
7
  api-test-schema-check contract-coverage-check api-test-matrix-check
8
8
  test-coverage-check test-obligation-check acceptance-test-results-check
9
9
  change-impact-check tdd-evidence-check test-task-binding-check
10
+ cross-track-consistency-check
10
11
  ].freeze
11
12
  EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
12
13
 
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "yaml"
7
+
8
+ options = { feature: nil, json: false }
9
+ OptionParser.new do |opts|
10
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
11
+ opts.on("--json") { options[:json] = true }
12
+ end.parse!
13
+
14
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
15
+ feature = options[:feature].to_s
16
+ raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
17
+
18
+ root = Dir.pwd
19
+ base = File.join(root, ".planning", "features", feature)
20
+ files = {
21
+ "api_contract" => File.join(base, "api-contract.md"),
22
+ "api_call_guide" => File.join(base, "api-call-guide.md"),
23
+ "frontend_spec" => File.join(base, "前端技术方案.md"),
24
+ "backend_spec" => File.join(base, "后端技术方案.md"),
25
+ "api_supplement" => File.join(base, "api-supplement-requests.md"),
26
+ }
27
+ errors = []
28
+ missing = files.select { |_, path| !File.file?(path) }.keys
29
+ errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
30
+
31
+ read = lambda do |key|
32
+ path = files.fetch(key)
33
+ File.file?(path) ? File.read(path) : ""
34
+ end
35
+ contract = read.call("api_contract")
36
+ guide = read.call("api_call_guide")
37
+ frontend = read.call("frontend_spec")
38
+
39
+ # Endpoint references are deliberately extracted from the authoritative API
40
+ # contract and checked for textual closure in both consumers. This is a
41
+ # deterministic reference-closure check; an AI integration summary is not used.
42
+ endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
43
+ endpoints = contract.scan(endpoint_pattern).flatten.uniq
44
+ unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
45
+ unless unreferenced.empty?
46
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
47
+ end
48
+
49
+ contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
50
+ {
51
+ "api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
52
+ "frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
53
+ }.each do |consumer, references|
54
+ ghosts = references.reject { |endpoint| contract_set[endpoint] }
55
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
56
+ end
57
+
58
+ supplement = read.call("api_supplement")
59
+ supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
60
+ supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
61
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
62
+ supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
63
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
64
+
65
+ meta_path = File.join(base, "api-call-guide.meta.json")
66
+ if File.file?(meta_path)
67
+ begin
68
+ meta = JSON.parse(File.read(meta_path))
69
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
70
+ pending = meta["pending_items"]
71
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
72
+ rescue JSON::ParserError => error
73
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
74
+ end
75
+ end
76
+
77
+ supplement_meta = File.join(base, "api-supplement-requests.meta.json")
78
+ if File.file?(supplement_meta)
79
+ begin
80
+ meta = JSON.parse(File.read(supplement_meta))
81
+ unresolved = meta["unresolved_supplement_count"]
82
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
83
+ rescue JSON::ParserError => error
84
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
85
+ end
86
+ end
87
+
88
+ result = {
89
+ "ok" => errors.empty?,
90
+ "checker_id" => "cross-track-consistency-check",
91
+ "errors" => errors,
92
+ "evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
93
+ }
94
+ puts(options[:json] ? JSON.generate(result) : result.to_yaml)
@@ -14,7 +14,7 @@ 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
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
- "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] },
17
+ "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-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] },
20
20
  "execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },