@guanlin4924/devflow-pi 0.1.37 → 0.1.39

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.37
14
+ npm install -g @guanlin4924/devflow-pi@0.1.38
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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, 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";
@@ -864,6 +864,27 @@ export default function devflowCore(pi: ExtensionAPI) {
864
864
  let controlState: SessionControlState = initialControlState();
865
865
  let activeContract: ActiveExecutionContract | undefined;
866
866
  let contractAgentCalls = 0;
867
+ let projectRoot: string | undefined;
868
+
869
+ async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
870
+ if (!cwd || !activeContract) return;
871
+ const feature = activeContract.feature;
872
+ const step = activeContract.step;
873
+ if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
874
+ const directory = path.join(cwd, ".planning", "evidence", feature);
875
+ const destination = path.join(directory, `${step}.engine-events.jsonl`);
876
+ await mkdir(directory, { recursive: true });
877
+ await appendFile(destination, `${JSON.stringify({
878
+ schema_version: 1,
879
+ timestamp: new Date().toISOString(),
880
+ session_id: sessionId,
881
+ feature,
882
+ step,
883
+ event,
884
+ collector: "devflow-runtime/harness",
885
+ ...extra,
886
+ })}\n`, "utf8");
887
+ }
867
888
 
868
889
  function controlGuide(state: SessionControlState): string[] {
869
890
  const title = "Devflow 操作指引";
@@ -1285,6 +1306,22 @@ export default function devflowCore(pi: ExtensionAPI) {
1285
1306
  return { result, snapshot: parseJsonResult(result) };
1286
1307
  }
1287
1308
 
1309
+ async function inspectWorkflowWithRegistryRecovery(cwd: string): Promise<{ result: ProcessResult; snapshot: Record<string, unknown> | null }> {
1310
+ let inspected = await inspectWorkflow(cwd);
1311
+ const errors = Array.isArray(inspected.snapshot?.state_validation && (inspected.snapshot.state_validation as Record<string, unknown>).errors)
1312
+ ? (inspected.snapshot!.state_validation as Record<string, unknown>).errors as Array<Record<string, unknown>>
1313
+ : [];
1314
+ const onlyRegistryDrift = inspected.snapshot?.reason === "state_validation_failed" &&
1315
+ errors.length > 0 && errors.every(error => error.code === "artifact_registry_revision_mismatch");
1316
+ if (!onlyRegistryDrift) return inspected;
1317
+ const synced = await runRuby(cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
1318
+ "--write", "--no-preserve", "--json",
1319
+ ]);
1320
+ if (!synced.ok) return inspected;
1321
+ inspected = await inspectWorkflow(cwd);
1322
+ return inspected;
1323
+ }
1324
+
1288
1325
  let activeWorkflowTurn = false;
1289
1326
  let pendingProjectInitialization: ProjectInitializationRequest | undefined;
1290
1327
  let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
@@ -1322,6 +1359,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1322
1359
  pi.setLabel("Devflow Project Runtime");
1323
1360
 
1324
1361
  pi.on("session_start", async (_event, ctx) => {
1362
+ projectRoot = ctx.cwd;
1325
1363
  sessionId = randomUUID();
1326
1364
  const result = await runHook(ctx.cwd, "start.py", {
1327
1365
  session_id: sessionId,
@@ -1362,7 +1400,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1362
1400
  }
1363
1401
  }
1364
1402
  if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && typeof rollover.id === "string") {
1365
- const workflow = await inspectWorkflow(ctx.cwd);
1403
+ const workflow = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1366
1404
  const expectedStateRevision = typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision : undefined;
1367
1405
  if (workflow.result.ok && workflow.snapshot && expectedStateRevision !== undefined && expectedStateRevision + 1 === rolloverState.revision) {
1368
1406
  pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
@@ -1373,8 +1411,13 @@ export default function devflowCore(pi: ExtensionAPI) {
1373
1411
  const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
1374
1412
  "consume", "--write", "--json", "--expected-revision", String(rolloverState.revision), "--target-session-id", currentSessionId,
1375
1413
  ]);
1376
- const after = await inspectWorkflow(ctx.cwd);
1414
+ const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1377
1415
  if (consumed.ok && after.result.ok && after.snapshot) {
1416
+ if (after.snapshot.action === "blocked") {
1417
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "启动恢复校验失败" });
1418
+ ctx.ui.notify(`会话已恢复,但工作流校验失败,未启动 AI:${String(after.snapshot.reason ?? "state validation failed")}`, "error");
1419
+ return;
1420
+ }
1378
1421
  setControlState(ctx, executingState("恢复已认领的干净会话"));
1379
1422
  await triggerWorkflow(after.snapshot, "approval");
1380
1423
  } else if (!consumed.ok) {
@@ -1421,13 +1464,23 @@ export default function devflowCore(pi: ExtensionAPI) {
1421
1464
  pendingRollover = undefined;
1422
1465
  activeContract = undefined;
1423
1466
  contractAgentCalls = 0;
1467
+ const next = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1468
+ if (!next.result.ok || !next.snapshot || next.snapshot.action === "blocked") {
1469
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "启动恢复校验失败" });
1470
+ ctx.ui.notify(`会话已切换,但工作流校验失败,未启动 AI:${String(next.snapshot?.reason ?? next.result.stderr ?? next.result.stdout ?? "state validation failed")}`, "error");
1471
+ return;
1472
+ }
1424
1473
  setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
1425
- await triggerWorkflow(pending.snapshot, "approval");
1474
+ await triggerWorkflow(next.snapshot, "approval");
1426
1475
  });
1427
1476
 
1428
- pi.on("before_agent_start", async event => ({
1429
- systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY],
1430
- }));
1477
+ pi.on("before_agent_start", async event => {
1478
+ if (activeWorkflowTurn && activeContract) {
1479
+ await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
1480
+ await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
1481
+ }
1482
+ return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
1483
+ });
1431
1484
 
1432
1485
  pi.on("agent_end", async (event, ctx) => {
1433
1486
  if (!activeWorkflowTurn || event.willContinue) return;
@@ -1444,7 +1497,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1444
1497
  if (controlState.intent === "requirement-draft" && controlState.phase === "planning") {
1445
1498
  const requirementDraft = event.text.trim();
1446
1499
  if (!requirementDraft) return { handled: true };
1447
- const inspected = await inspectWorkflow(ctx.cwd);
1500
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1448
1501
  if (!inspected.result.ok || !inspected.snapshot) {
1449
1502
  ctx.ui.notify(`需求分诊启动失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
1450
1503
  return { handled: true };
@@ -1472,7 +1525,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1472
1525
  // fresh-session handoff. Keep approval useful in this recovery state, but
1473
1526
  // retry only the handoff; never repeat the already-persisted transition.
1474
1527
  if (controlState.phase === "blocked" && controlState.label === "会话轮换准备失败" && controlAction === "approve") {
1475
- const inspected = await inspectWorkflow(ctx.cwd);
1528
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1476
1529
  if (!inspected.result.ok || !inspected.snapshot) {
1477
1530
  ctx.ui.notify(`会话交接重试失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
1478
1531
  return { handled: true };
@@ -1498,7 +1551,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1498
1551
  return { handled: true };
1499
1552
  }
1500
1553
  if (controlAction === "approve") {
1501
- const inspected = await inspectWorkflow(ctx.cwd);
1554
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1502
1555
  if (!inspected.result.ok || !inspected.snapshot) {
1503
1556
  ctx.ui.notify(`审批处理失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
1504
1557
  return { handled: true };
@@ -1516,14 +1569,14 @@ export default function devflowCore(pi: ExtensionAPI) {
1516
1569
  "--json",
1517
1570
  "--expected-revision",
1518
1571
  String(revision),
1519
- ...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
1572
+ ...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
1520
1573
  ]);
1521
1574
  if (!result.ok) {
1522
1575
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
1523
1576
  ctx.ui.notify(`未推进:${result.stderr || result.stdout}`, "error");
1524
1577
  return { handled: true };
1525
1578
  }
1526
- const after = await inspectWorkflow(ctx.cwd);
1579
+ const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
1527
1580
  activeContract = undefined;
1528
1581
  contractAgentCalls = 0;
1529
1582
  if (!after.result.ok || !after.snapshot || after.snapshot.action === "workflow_complete") {
@@ -1664,6 +1717,18 @@ export default function devflowCore(pi: ExtensionAPI) {
1664
1717
  "Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
1665
1718
  };
1666
1719
  }
1720
+ if (isUserConfirmedStepTransition && activeContract) {
1721
+ const evidence = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/machine_evidence.rb", [
1722
+ "--feature", activeContract.feature, "--step", activeContract.step, "--write", "--json",
1723
+ ]);
1724
+ const evidencePayload = parseJsonResult(evidence);
1725
+ if (!evidence.ok || !evidencePayload || evidencePayload.complete !== true) {
1726
+ return {
1727
+ block: true,
1728
+ reason: `Devflow machine evidence is incomplete; collect independent runtime evidence before completing ${activeContract.step}.`,
1729
+ };
1730
+ }
1731
+ }
1667
1732
  if (
1668
1733
  !toolName.startsWith("devflow_") &&
1669
1734
  ["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
@@ -1740,6 +1805,12 @@ export default function devflowCore(pi: ExtensionAPI) {
1740
1805
 
1741
1806
  pi.on("tool_result", async (event, ctx) => {
1742
1807
  if (event.isError) return;
1808
+ if (activeWorkflowTurn && activeContract) {
1809
+ await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
1810
+ engine: event.toolName,
1811
+ ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
1812
+ });
1813
+ }
1743
1814
  await runHook(ctx.cwd, "post_tool.py", {
1744
1815
  session_id: sessionId,
1745
1816
  cwd: ctx.cwd,
@@ -1840,7 +1911,11 @@ export default function devflowCore(pi: ExtensionAPI) {
1840
1911
  stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
1841
1912
  });
1842
1913
  }
1843
- args.push("--write", "--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
1914
+ args.push("--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
1915
+ // `write` is an explicit dry-run switch. Omit it only when the caller
1916
+ // explicitly asks for a preview; the normal approved proposal remains
1917
+ // a Core-owned write.
1918
+ if (params.write !== false) args.push("--write");
1844
1919
  if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
1845
1920
  }
1846
1921
  if (params.action === "confirm") {
@@ -2513,7 +2588,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2513
2588
  }
2514
2589
  args.push("--checkpoint-decision", params.decision);
2515
2590
  } else if (params.action === "cancel-feature") {
2516
- const inspected = await inspectWorkflow(ctx.cwd);
2591
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
2517
2592
  const snapshot = inspected.snapshot;
2518
2593
  const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : "当前需求";
2519
2594
  const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
@@ -2641,7 +2716,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2641
2716
  return;
2642
2717
  }
2643
2718
  if (trimmed === "explain-state") {
2644
- const inspected = await inspectWorkflow(ctx.cwd);
2719
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
2645
2720
  ctx.ui.notify(
2646
2721
  inspected.result.ok ? explainWorkflowSnapshot(inspected.snapshot) : `状态读取失败:${inspected.result.stderr || inspected.result.stdout}`,
2647
2722
  inspected.result.ok ? "info" : "error",
@@ -2650,7 +2725,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2650
2725
  return;
2651
2726
  }
2652
2727
  if (trimmed === "recover") {
2653
- const inspected = await inspectWorkflow(ctx.cwd);
2728
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
2654
2729
  const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
2655
2730
  const revision = plan?.expected_revision;
2656
2731
  if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
@@ -2689,7 +2764,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2689
2764
  return;
2690
2765
  }
2691
2766
  if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
2692
- const inspected = await inspectWorkflow(ctx.cwd);
2767
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
2693
2768
  const snapshot = inspected.snapshot;
2694
2769
  const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
2695
2770
  const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
@@ -2758,7 +2833,7 @@ export default function devflowCore(pi: ExtensionAPI) {
2758
2833
  ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
2759
2834
  return;
2760
2835
  }
2761
- const inspected = await inspectWorkflow(ctx.cwd);
2836
+ const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
2762
2837
  if (!inspected.result.ok || !inspected.snapshot) {
2763
2838
  ctx.ui.notify(
2764
2839
  `Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`,
@@ -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,8 @@ 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
28
30
  }
29
31
 
30
32
  OptionParser.new do |opts|
@@ -40,6 +42,7 @@ OptionParser.new do |opts|
40
42
  opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
41
43
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
42
44
  opts.on("--json", "Print JSON output") { options[:json] = true }
45
+ opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
43
46
  end.parse!
44
47
 
45
48
  root = Dir.pwd
@@ -64,6 +67,63 @@ def load_workflow(root, state)
64
67
  [relative, load_yaml_file(path)]
65
68
  end
66
69
 
70
+ def gate_contract_enabled?(workflow)
71
+ workflow.dig("governance", "gate_contract_version").to_i >= 3
72
+ end
73
+
74
+ # Build the runtime view from the existing workflow declaration. This keeps
75
+ # governance.step_checkers as the single checker namespace and avoids creating
76
+ # a second hand-maintained per-step transition table.
77
+ def transition_gate_contract(workflow, step)
78
+ checkpoints = workflow["checkpoints"] || {}
79
+ checkpoint_id = step["checkpoint"]
80
+ checkpoint = checkpoint_id && checkpoints[checkpoint_id]
81
+ track_names = Array(workflow["parallel_windows"]).flat_map do |window|
82
+ next [] unless window.is_a?(Hash) && window["branches"].is_a?(Hash)
83
+ window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
84
+ end
85
+ allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
86
+ reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
87
+ next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
88
+ entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
89
+ end
90
+ {
91
+ "gate_id" => "#{step.fetch("id")}-gate-v1",
92
+ "step_id" => step.fetch("id"),
93
+ "topology" => {
94
+ "allowed_tracks" => allowed_tracks,
95
+ "allowed_predecessors" => Array(step["depends_on"]),
96
+ "allowed_reentry" => reentries,
97
+ "checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
98
+ },
99
+ "entry" => { "required_inputs" => Array(step["inputs"]) },
100
+ "outputs" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
101
+ "quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
102
+ "concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
103
+ "recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
104
+ }
105
+ end
106
+
107
+ def transition_gate_preflight(workflow, state, step, current_revision, checkpoint_authorized = false, transition_kind = nil)
108
+ return nil unless gate_contract_enabled?(workflow)
109
+
110
+ contract = transition_gate_contract(workflow, step)
111
+ track = state["active_track"].to_s
112
+ track = "main" unless %w[A B].include?(track)
113
+ GateRuntime.evaluate(
114
+ contract: contract,
115
+ workflow_state: state,
116
+ current_revision: current_revision,
117
+ expected_revision: current_revision,
118
+ track: track,
119
+ source: "advance_state",
120
+ root: Dir.pwd,
121
+ check_outputs: step["id"].to_s == state["current_step"].to_s,
122
+ transition_kind: transition_kind,
123
+ checkpoint_authorized: checkpoint_authorized
124
+ )
125
+ end
126
+
67
127
  def interpolate_path(path, state)
68
128
  path
69
129
  .gsub("{feature}", state.fetch("current_feature", "").to_s)
@@ -516,6 +576,39 @@ result =
516
576
  )
517
577
  end
518
578
 
579
+ # GateRuntime is a read-only boundary check. It runs while the project
580
+ # lock is held and before any candidate state mutation; the existing
581
+ # validator and advance_state CAS remain the final write guard.
582
+ if result.nil? && gate_contract_enabled?(workflow) &&
583
+ !%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]
586
+ options[:complete_step]
587
+ elsif options[:confirm_checkpoint]
588
+ state["current_step"]
589
+ else
590
+ state["current_step"]
591
+ end
592
+ target_step = step_by_id[target_step_id]
593
+ if target_step.nil?
594
+ result = fail_result("gate_target_step_unknown", "step" => target_step_id)
595
+ elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
596
+ result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
597
+ else
598
+ transition_kind = if options[:confirm_checkpoint]
599
+ "confirm_checkpoint"
600
+ elsif options[:enter_failure_branch]
601
+ "enter_failure_branch"
602
+ elsif options[:exit_failure_branch]
603
+ "exit_failure_branch"
604
+ end
605
+ gate_result = transition_gate_preflight(workflow, state, target_step, current_revision, options[:runtime_confirmed], transition_kind)
606
+ if gate_result && gate_result["verdict"] != "allow"
607
+ result = fail_result("gate_boundary_rejected", "gate" => gate_result)
608
+ end
609
+ end
610
+ end
611
+
519
612
  if result.nil? && options[:checkpoint_decision]
520
613
  state["checkpoint_decision"] = options[:checkpoint_decision]
521
614
  changed = true
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ module CheckerRegistry
5
+ DECLARED = %w[
6
+ requirement-graph-check slice-independence-check acceptance-coverage-check
7
+ api-test-schema-check contract-coverage-check api-test-matrix-check
8
+ test-coverage-check test-obligation-check acceptance-test-results-check
9
+ change-impact-check tdd-evidence-check test-task-binding-check
10
+ ].freeze
11
+ EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
12
+
13
+ module_function
14
+
15
+ def known?(id)
16
+ (DECLARED + EMBEDDED).include?(id.to_s)
17
+ end
18
+
19
+ def kind(id)
20
+ return "declared" if DECLARED.include?(id.to_s)
21
+ return "embedded" if EMBEDDED.include?(id.to_s)
22
+ nil
23
+ end
24
+
25
+ def validate!(ids)
26
+ unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
27
+ raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
28
+ true
29
+ end
30
+ end
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ module GateContract
7
+ module_function
8
+
9
+ def load(path)
10
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
11
+ validate!(value)
12
+ value
13
+ rescue Psych::Exception => e
14
+ raise ArgumentError, "invalid GateContract YAML: #{e.message}"
15
+ end
16
+
17
+ def validate!(contract)
18
+ raise ArgumentError, "GateContract must be a mapping" unless contract.is_a?(Hash)
19
+ %w[gate_id step_id topology entry outputs quality recovery].each do |field|
20
+ raise ArgumentError, "GateContract missing #{field}" unless contract.key?(field)
21
+ end
22
+ topology = contract["topology"]
23
+ raise ArgumentError, "topology must be a mapping" unless topology.is_a?(Hash)
24
+ %w[allowed_tracks allowed_predecessors].each do |field|
25
+ raise ArgumentError, "#{field} must be an array" unless topology[field].is_a?(Array)
26
+ end
27
+ raise ArgumentError, "allowed_reentry must be an array" unless topology["allowed_reentry"].is_a?(Array)
28
+ validate_reentries!(topology["allowed_reentry"])
29
+ checkpoint = topology["checkpoint"]
30
+ validate_checkpoint!(checkpoint) unless checkpoint.nil?
31
+ concurrency = contract["concurrency"]
32
+ raise ArgumentError, "concurrency must be a mapping" unless concurrency.is_a?(Hash)
33
+ %w[project_lock optimistic_revision registry_view].each do |field|
34
+ raise ArgumentError, "concurrency missing #{field}" unless concurrency.key?(field)
35
+ end
36
+ raise ArgumentError, "project_lock must be required" unless concurrency["project_lock"].to_s == "required"
37
+ raise ArgumentError, "optimistic_revision must be required" unless concurrency["optimistic_revision"].to_s == "required"
38
+ raise ArgumentError, "registry_view must be candidate_overlay" unless concurrency["registry_view"].to_s == "candidate_overlay"
39
+ recovery = contract["recovery"]
40
+ raise ArgumentError, "recovery must be a mapping" unless recovery.is_a?(Hash)
41
+ forbidden = Array(recovery["allowed"]).select { |action| action.to_s.start_with?("reenter_") || %w[confirm_checkpoint transition].include?(action.to_s) }
42
+ raise ArgumentError, "state transition cannot be an automatic recovery action: #{forbidden.join(", ")}" unless forbidden.empty?
43
+ contract
44
+ end
45
+
46
+ def validate_reentries!(entries)
47
+ entries.each do |entry|
48
+ raise ArgumentError, "reentry must be a mapping" unless entry.is_a?(Hash)
49
+ source = entry["source"]
50
+ target = entry["target"]
51
+ raise ArgumentError, "reentry source/target are required" unless source.is_a?(Hash) && target.is_a?(Hash)
52
+ %w[flow step].each { |field| raise ArgumentError, "reentry target missing #{field}" if target[field].to_s.empty? }
53
+ raise ArgumentError, "reentry must declare checkpoint reconfirmation" unless entry.key?("requires_checkpoint_reconfirm")
54
+ end
55
+ end
56
+
57
+ def validate_checkpoint!(checkpoint)
58
+ raise ArgumentError, "checkpoint must be a mapping" unless checkpoint.is_a?(Hash)
59
+ %w[id producing_step required_outputs required_meta_output prompt confirm_options skip_policy resume_behavior].each do |field|
60
+ raise ArgumentError, "checkpoint missing #{field}" unless checkpoint.key?(field)
61
+ end
62
+ raise ArgumentError, "checkpoint confirm_options must be an array" unless checkpoint["confirm_options"].is_a?(Array)
63
+ end
64
+ end
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ module GateResult
5
+ VERDICTS = %w[allow allow_with_warning await_user waiting_external_input block_unsafe].freeze
6
+ CONTROL_PLANE_ERROR = "control_plane_error"
7
+
8
+ module_function
9
+
10
+ def build(verdict:, gate_id:, step_id:, errors: [], warnings: [], recovery: nil, evidence: {}, revisions: {})
11
+ valid = VERDICTS.include?(verdict.to_s) || verdict.to_s == CONTROL_PLANE_ERROR
12
+ raise ArgumentError, "invalid gate verdict: #{verdict}" unless valid
13
+
14
+ {
15
+ "ok" => %w[allow allow_with_warning].include?(verdict.to_s),
16
+ "verdict" => verdict.to_s,
17
+ "gate_id" => gate_id.to_s,
18
+ "step_id" => step_id.to_s,
19
+ "errors" => Array(errors),
20
+ "warnings" => Array(warnings),
21
+ "recovery" => recovery,
22
+ "evidence" => evidence.is_a?(Hash) ? evidence : {},
23
+ "revisions" => revisions.is_a?(Hash) ? revisions : {}
24
+ }
25
+ end
26
+
27
+ def allow(gate_id:, step_id:, **kwargs)
28
+ build(verdict: "allow", gate_id: gate_id, step_id: step_id, **kwargs)
29
+ end
30
+
31
+ def await_user(gate_id:, step_id:, recovery: nil, **kwargs)
32
+ build(verdict: "await_user", gate_id: gate_id, step_id: step_id, recovery: recovery, **kwargs)
33
+ end
34
+
35
+ def block(gate_id:, step_id:, code:, message:, recovery: nil, details: {}, **kwargs)
36
+ error = { "code" => code.to_s, "message" => message.to_s }
37
+ error.merge!(details) if details.is_a?(Hash)
38
+ build(verdict: "block_unsafe", gate_id: gate_id, step_id: step_id, errors: [error], recovery: recovery, **kwargs)
39
+ end
40
+ end
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "gate_result"
5
+ require_relative "gate_contract"
6
+ require_relative "checker_registry"
7
+
8
+ module GateRuntime
9
+ WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
10
+
11
+ module_function
12
+
13
+ def validate_contract!(contract)
14
+ GateContract.validate!(contract)
15
+ CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
16
+ contract
17
+ end
18
+
19
+ def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
20
+ validate_contract!(contract)
21
+ gate_id = contract.fetch("gate_id")
22
+ step_id = contract.fetch("step_id")
23
+ base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
24
+ return GateResult.block(**base, code: "REVISION_CONFLICT", message: "current revision does not match expected revision", recovery: { "kind" => "reinspect", "expected_revision" => expected_revision }) unless expected_revision.nil? || current_revision.to_i == expected_revision.to_i
25
+ unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
26
+ return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
27
+ end
28
+ unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch].include?(transition_kind)
29
+ allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
30
+ if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
31
+ return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
32
+ end
33
+ allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
34
+ previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
35
+ if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
36
+ return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
37
+ end
38
+ if root
39
+ missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
40
+ return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
41
+ if check_outputs
42
+ missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
43
+ return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
44
+ end
45
+ end
46
+ end
47
+ if transition_kind == "confirm_checkpoint"
48
+ checkpoint_id = workflow_state["checkpoint"].to_s
49
+ checkpoint = contract.dig("topology", "checkpoint")
50
+ checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
51
+ unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
52
+ return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
53
+ end
54
+ unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
55
+ return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
56
+ end
57
+ end
58
+ GateResult.allow(**base)
59
+ rescue KeyError, ArgumentError => e
60
+ GateResult.block(gate_id: contract.is_a?(Hash) ? contract["gate_id"] : "unknown", step_id: contract.is_a?(Hash) ? contract["step_id"] : "unknown", code: "GATE_CONTRACT_INVALID", message: e.message)
61
+ end
62
+
63
+ def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
64
+ boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
65
+ return boundary unless boundary["verdict"] == "allow"
66
+ result = checker.call
67
+ raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
68
+ result.merge(
69
+ "gate_id" => result["gate_id"] || boundary["gate_id"],
70
+ "step_id" => result["step_id"] || boundary["step_id"],
71
+ "evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
72
+ "revisions" => boundary.fetch("revisions", {}).merge(result.fetch("revisions", {}))
73
+ )
74
+ end
75
+
76
+ def required_paths(entries, root, state)
77
+ Array(entries).each_with_object([]) do |entry, missing|
78
+ raw = entry.is_a?(Hash) ? entry["path"] : entry
79
+ next if raw.to_s.empty?
80
+ relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
81
+ absolute = File.expand_path(relative, root)
82
+ root_real = File.realpath(root)
83
+ actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
84
+ next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
85
+ missing << relative
86
+ end
87
+ end
88
+ end