@guanlin4924/devflow-pi 0.1.38 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.38",
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 操作指引";
@@ -1338,6 +1359,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1338
1359
  pi.setLabel("Devflow Project Runtime");
1339
1360
 
1340
1361
  pi.on("session_start", async (_event, ctx) => {
1362
+ projectRoot = ctx.cwd;
1341
1363
  sessionId = randomUUID();
1342
1364
  const result = await runHook(ctx.cwd, "start.py", {
1343
1365
  session_id: sessionId,
@@ -1452,9 +1474,13 @@ export default function devflowCore(pi: ExtensionAPI) {
1452
1474
  await triggerWorkflow(next.snapshot, "approval");
1453
1475
  });
1454
1476
 
1455
- pi.on("before_agent_start", async event => ({
1456
- systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY],
1457
- }));
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
+ });
1458
1484
 
1459
1485
  pi.on("agent_end", async (event, ctx) => {
1460
1486
  if (!activeWorkflowTurn || event.willContinue) return;
@@ -1543,7 +1569,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1543
1569
  "--json",
1544
1570
  "--expected-revision",
1545
1571
  String(revision),
1546
- ...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
1572
+ ...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
1547
1573
  ]);
1548
1574
  if (!result.ok) {
1549
1575
  setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
@@ -1691,6 +1717,18 @@ export default function devflowCore(pi: ExtensionAPI) {
1691
1717
  "Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
1692
1718
  };
1693
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
+ }
1694
1732
  if (
1695
1733
  !toolName.startsWith("devflow_") &&
1696
1734
  ["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
@@ -1767,6 +1805,12 @@ export default function devflowCore(pi: ExtensionAPI) {
1767
1805
 
1768
1806
  pi.on("tool_result", async (event, ctx) => {
1769
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
+ }
1770
1814
  await runHook(ctx.cwd, "post_tool.py", {
1771
1815
  session_id: sessionId,
1772
1816
  cwd: ctx.cwd,
@@ -1867,7 +1911,11 @@ export default function devflowCore(pi: ExtensionAPI) {
1867
1911
  stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
1868
1912
  });
1869
1913
  }
1870
- 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");
1871
1919
  if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
1872
1920
  }
1873
1921
  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,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
@@ -10,6 +10,7 @@ require "optparse"
10
10
  require "yaml"
11
11
  require_relative "control_io"
12
12
  require_relative "requirement_graph"
13
+ require_relative "checker_registry"
13
14
 
14
15
  options = { feature: nil, step: nil, contract: nil, write: false, json: false }
15
16
  OptionParser.new do |o|
@@ -33,6 +34,69 @@ def interpolate(path, state)
33
34
  path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
34
35
  end
35
36
 
37
+ def file_evidence(root, relative)
38
+ absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact)
39
+ return { "path" => relative, "exists" => false } unless File.file?(absolute)
40
+
41
+ { "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
42
+ end
43
+
44
+ def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results)
45
+ source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
46
+ authority = source_nodes.map do |node|
47
+ evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
48
+ {
49
+ "source_id" => node["id"], "authority" => evidence["authority"], "path" => evidence["path"] || evidence["ref"],
50
+ "fingerprint" => evidence["sha256"] || evidence["source_document_fingerprint"]
51
+ }.compact
52
+ end
53
+
54
+ # The harness may publish an append-only event log beside the machine pack.
55
+ # Absence is represented as unavailable, never as false/zero, so the pack
56
+ # cannot manufacture proof that an engine did not invoke AI.
57
+ event_path = File.join(".planning", "evidence", feature, "#{step}.engine-events.jsonl")
58
+ event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
59
+ events = if File.file?(event_absolute)
60
+ File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
61
+ next if line.strip.empty?
62
+
63
+ begin
64
+ parsed << JSON.parse(line)
65
+ rescue JSON::ParserError
66
+ # A malformed event is not evidence of a clean execution;
67
+ # retain the valid events and let the collector expose the
68
+ # observed subset without inventing a zero count.
69
+ end
70
+ end
71
+ else
72
+ nil
73
+ end
74
+ ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
75
+
76
+ {
77
+ "collector" => "core.machine_evidence/v1",
78
+ "state" => { "revision" => state["revision"], "step" => step },
79
+ "source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
80
+ "candidate_binding" => {
81
+ "status" => "observed",
82
+ "graph_revision" => graph["revision"],
83
+ "test_case_ids" => Array(graph["nodes"]).each_with_object([]) { |node, ids| ids << node["id"] if node.is_a?(Hash) && node["kind"] == "test_case" }
84
+ },
85
+ "structured_execution" => {
86
+ "status" => "observed",
87
+ "required_files" => files,
88
+ "checker_ids" => checker_results.map { |item| item["id"] }
89
+ },
90
+ "engine_evidence" => {
91
+ "collector" => events ? "harness.engine-events/v1" : "unavailable",
92
+ "event_log" => event_path,
93
+ "ai_runtime_initialized" => events ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
94
+ "ai_invocation_count" => events ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
95
+ "evidence_status" => events ? "observed" : "unavailable"
96
+ }
97
+ }
98
+ end
99
+
36
100
  def configured_checkers(workflow, step_id)
37
101
  explicit = workflow.dig("governance", "step_checkers", step_id)
38
102
  checkers = if explicit
@@ -98,7 +162,7 @@ begin
98
162
  unless state["current_feature"] == options[:feature] && state["current_step"] == options[:step]
99
163
  result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
100
164
  puts JSON.pretty_generate(result)
101
- exit 0
165
+ exit 1
102
166
  end
103
167
  workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
104
168
  workflow_raw = File.read(workflow_path)
@@ -121,6 +185,7 @@ begin
121
185
  graph, = RequirementGraph.load_graph(root, options[:feature])
122
186
  checkers = RequirementGraph.checker_results(graph)
123
187
  selected = configured_checkers(workflow, options[:step])
188
+ CheckerRegistry.validate!(selected)
124
189
  gate = workflow.dig("governance", "requirement_graph_gate").to_s
125
190
  enforced = gate == "enforced"
126
191
  checker_results = selected.map do |id|
@@ -133,6 +198,7 @@ begin
133
198
  end
134
199
  graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
135
200
  checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
201
+ engine_evidence_gate = (step["engine_evidence_gate"] || workflow.dig("governance", "engine_evidence_gate")).to_s
136
202
  contract_hash = nil
137
203
  if options[:contract]
138
204
  contract_path = File.expand_path(options[:contract], root)
@@ -140,14 +206,18 @@ begin
140
206
  contract_hash = Digest::SHA256.file(contract_path).hexdigest
141
207
  end
142
208
  node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
209
+ independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results)
210
+ engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
211
+ evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
143
212
  pack = {
144
213
  "schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
145
214
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
146
- "contract_hash" => contract_hash, "files" => files, "checkers" => checker_results,
215
+ "contract_hash" => contract_hash, "files" => files, "checkers" => checker_results, "evidence" => independent,
147
216
  "counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
148
217
  "stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
149
218
  "view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
150
- "complete" => files.all? { |item| item["exists"] } && checker_passed,
219
+ "complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
220
+ "evidence_complete" => evidence_complete,
151
221
  "gate_mode" => gate.empty? ? "observe" : gate
152
222
  }.compact
153
223
  destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}.machine.json")
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Canonical wayfinder quality checker. It validates references and structured
5
+ # coverage only; AI-provided equivalence labels are never trusted.
6
+ require "digest"
7
+ require "json"
8
+ require "optparse"
9
+ require "yaml"
10
+ require_relative "requirement_graph"
11
+
12
+ module RequirementCoverage
13
+ module_function
14
+
15
+ def validate(root:, feature:)
16
+ directory = RequirementGraph.feature_dir(root, feature)
17
+ errors = []
18
+ source = load(File.join(directory, "source_units.yaml"), errors, "SOURCE_UNITS_MISSING")
19
+ coverage = load(File.join(directory, "coverage.yaml"), errors, "COVERAGE_ARTIFACT_MISSING")
20
+ return result(errors, {}, {}) unless source && coverage
21
+ validate_source(source, errors)
22
+ validate_coverage(coverage, errors)
23
+ return result(errors, {}, {}) unless errors.empty?
24
+
25
+ units = Array(source["units"])
26
+ unit_by_id = units.to_h { |unit| [unit["stable_unit_id"].to_s, unit] }
27
+ assertions = units.flat_map { |unit| Array(unit["required_assertions"]) }
28
+ assertion_by_id = assertions.to_h { |assertion| [assertion["assertion_id"].to_s, assertion] }
29
+ claims = Array(coverage["claims"])
30
+ claim_ids = {}
31
+ covered = {}
32
+ uncertain = {}
33
+ approval_required = {}
34
+
35
+ claims.each do |claim|
36
+ id = claim["claim_id"].to_s
37
+ duplicate(errors, claim_ids, id, "DUPLICATE_CLAIM_ID")
38
+ unit_id = claim["source_unit_id"].to_s
39
+ unless unit_by_id.key?(unit_id)
40
+ add(errors, "CLAIM_WITHOUT_UNIT", "claim references an unknown source unit", claim_id: id)
41
+ next
42
+ end
43
+ Array(claim["assertion_ids"]).each do |assertion_id|
44
+ assertion = assertion_by_id[assertion_id.to_s]
45
+ unless assertion && assertion["source_unit_id"].to_s == unit_id
46
+ add(errors, "ASSERTION_REF_NOT_FOUND", "claim references an unknown assertion", claim_id: id, assertion_id: assertion_id)
47
+ next
48
+ end
49
+ relation = compare(assertion, claim["inherited_constraints"])
50
+ case relation
51
+ when "equivalent" then covered[assertion_id.to_s] = true
52
+ when "uncertain" then uncertain[assertion_id.to_s] = true
53
+ when "scope_widened", "constraint_tightened"
54
+ approval = claim["approval"]
55
+ expected_content_fingerprint = sha(canonical(claim["inherited_constraints"]))
56
+ if approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["claim_id"].to_s == id && approval["assertion_id"].to_s == assertion_id.to_s && approval["relation"].to_s == relation && approval["content_fingerprint"].to_s == expected_content_fingerprint && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
57
+ covered[assertion_id.to_s] = true
58
+ else
59
+ approval_required[assertion_id.to_s] = true
60
+ end
61
+ else
62
+ add(errors, relation == "under_coverage" ? "ASSERTION_UNCOVERED" : "CONTRADICTORY_CLAIM", "claim is not an equivalent coverage", claim_id: id, assertion_id: assertion_id, relation: relation)
63
+ end
64
+ end
65
+ end
66
+
67
+ disposition_by_id = {}
68
+ Array(coverage["units"]).each do |entry|
69
+ unit_id = entry["unit_id"].to_s
70
+ duplicate(errors, disposition_by_id, unit_id, "DUPLICATE_COVERAGE")
71
+ add(errors, "UNIT_REF_NOT_FOUND", "coverage references an unknown unit", unit_id: unit_id) unless unit_by_id.key?(unit_id)
72
+ disposition = entry["disposition"]
73
+ unless disposition.is_a?(Hash) && %w[active deferred out_of_scope merged].include?(disposition["kind"].to_s)
74
+ add(errors, "DISPOSITION_SCHEMA_INVALID", "invalid unit disposition", unit_id: unit_id)
75
+ next
76
+ end
77
+ if %w[deferred out_of_scope].include?(disposition["kind"].to_s)
78
+ approval = disposition["approval"]
79
+ unless approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["unit_id"].to_s == unit_id && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
80
+ add(errors, "DISPOSITION_APPROVAL_MISSING", "deferred/out_of_scope requires a current unit approval", unit_id: unit_id)
81
+ end
82
+ end
83
+ end
84
+ unit_by_id.each_key { |id| add(errors, "UNIT_DISPOSITION_MISSING", "source unit has no disposition", unit_id: id) unless disposition_by_id.key?(id) }
85
+ active = disposition_by_id.select { |_id, entry| entry.dig("disposition", "kind").to_s == "active" }.keys
86
+ active.each do |unit_id|
87
+ Array(unit_by_id[unit_id]["required_assertions"]).each do |assertion|
88
+ id = assertion["assertion_id"].to_s
89
+ next if covered[id] || uncertain[id] || approval_required[id]
90
+ add(errors, "ASSERTION_UNCOVERED", "active assertion has no valid claim", unit_id: unit_id, assertion_id: id)
91
+ end
92
+ end
93
+ counts = { "unit_count" => units.length, "assertion_count" => assertions.length, "claim_count" => claims.length, "covered_assertion_count" => covered.length, "uncertain_assertion_count" => uncertain.length, "approval_required_assertion_count" => approval_required.length }
94
+ result(errors, counts, (uncertain.any? || approval_required.any?) ? "await_user" : nil)
95
+ rescue JSON::ParserError, Psych::Exception, ArgumentError => e
96
+ result([{ "code" => "COVERAGE_SCHEMA_INVALID", "message" => e.message }], {}, {})
97
+ end
98
+
99
+ def load(path, errors, code)
100
+ unless File.file?(path)
101
+ add(errors, code, "required coverage artifact is missing", path: path)
102
+ return nil
103
+ end
104
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
105
+ raise ArgumentError, "coverage artifact must be a mapping" unless value.is_a?(Hash)
106
+ value
107
+ end
108
+
109
+ def validate_source(source, errors)
110
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_units schema_version must be 1") unless source["schema_version"].to_i == 1
111
+ add(errors, "SOURCE_UNITS_NOT_CORE_GENERATED", "source_units must be produced by Core") unless source["produced_by"].to_s == "core.source_unitizer/v1"
112
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_document_fingerprint is required") if source["source_document_fingerprint"].to_s.empty?
113
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "units must be an array") unless source["units"].is_a?(Array)
114
+ Array(source["units"]).each do |unit|
115
+ add(errors, "UNIT_SCHEMA_INVALID", "stable_unit_id is required") if unit["stable_unit_id"].to_s.empty?
116
+ add(errors, "SOURCE_UNIT_REUSED_STALE", "unit source fingerprint mismatch", unit_id: unit["stable_unit_id"]) unless unit["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
117
+ add(errors, "EXCERPT_MODIFIED", "excerpt fingerprint mismatch", unit_id: unit["stable_unit_id"]) if unit["excerpt_fingerprint"] && sha(unit["excerpt"]) != unit["excerpt_fingerprint"].to_s
118
+ end
119
+ end
120
+
121
+ def validate_coverage(coverage, errors)
122
+ add(errors, "COVERAGE_SCHEMA_INVALID", "coverage schema_version must be 1") unless coverage["schema_version"].to_i == 1
123
+ %w[claims units].each { |key| add(errors, "COVERAGE_SCHEMA_INVALID", "#{key} must be an array") unless coverage[key].is_a?(Array) }
124
+ end
125
+
126
+ def compare(assertion, inherited)
127
+ return "uncertain" unless inherited.is_a?(Hash)
128
+ expected = assertion.select { |key, _| %w[type polarity modality predicate condition role_scope order_constraint data_constraint exception].include?(key) }
129
+ actual = inherited.select { |key, _| expected.key?(key) }
130
+ return "equivalent" if canonical(expected) == canonical(actual)
131
+ return "under_coverage" if expected["polarity"] && actual["polarity"] && expected["polarity"] != actual["polarity"]
132
+ return "under_coverage" if expected["modality"] == "must" && actual["modality"] == "should"
133
+ if expected["role_scope"].is_a?(Hash) && actual["role_scope"].is_a?(Hash)
134
+ expected_include = Array(expected["role_scope"]["include"]); actual_include = Array(actual["role_scope"]["include"])
135
+ return "scope_widened" if (actual_include - expected_include).any?
136
+ return "under_coverage" if (expected_include - actual_include).any?
137
+ end
138
+ "uncertain"
139
+ end
140
+
141
+ def canonical(value)
142
+ JSON.generate(value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value)
143
+ end
144
+ def canonical_value(value)
145
+ value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value.is_a?(Array) ? value.map { |item| canonical_value(item) } : value
146
+ end
147
+ def sha(value)
148
+ Digest::SHA256.hexdigest(value.to_s)
149
+ end
150
+ def duplicate(errors, seen, id, code)
151
+ add(errors, code, "duplicate identifier", id: id) if seen.key?(id)
152
+ seen[id] = true
153
+ end
154
+ def add(errors, code, message, **extra)
155
+ errors << { "code" => code, "message" => message }.merge(extra)
156
+ end
157
+ def result(errors, counts, forced_verdict = nil)
158
+ verdict = if errors.any? then "block_unsafe" elsif forced_verdict then forced_verdict else "allow" end
159
+ { "ok" => verdict == "allow", "verdict" => verdict, "errors" => errors, "warnings" => [], "counts" => counts, "evidence" => { "checker" => "requirement-coverage-check" } }
160
+ end
161
+ end
162
+
163
+ if $PROGRAM_NAME == __FILE__
164
+ options = {}
165
+ OptionParser.new do |opts|
166
+ opts.on("--root PATH") { |value| options[:root] = value }
167
+ opts.on("--feature NAME") { |value| options[:feature] = value }
168
+ end.parse!
169
+ abort("--root and --feature are required") unless options[:root] && options[:feature]
170
+ payload = RequirementCoverage.validate(root: options[:root], feature: options[:feature])
171
+ puts JSON.pretty_generate(payload)
172
+ exit(payload["verdict"] == "allow" ? 0 : 1)
173
+ end
@@ -63,6 +63,10 @@ module RequirementGraph
63
63
  JSON.generate(value)
64
64
  end
65
65
 
66
+ def deep_clone(value)
67
+ JSON.parse(JSON.generate(value))
68
+ end
69
+
66
70
  def fingerprint(value)
67
71
  Digest::SHA256.hexdigest(canonical_json(value))
68
72
  end
@@ -1000,20 +1004,22 @@ if __FILE__ == $PROGRAM_NAME
1000
1004
  { "ok" => true, "path" => relative, "context" => ctx }
1001
1005
  end
1002
1006
  when "propose"
1003
- raise ArgumentError, "--write is required" unless options[:write]
1004
1007
  raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
1005
1008
  if graph["revision"] != options[:expected_revision]
1006
1009
  { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
1007
1010
  else
1008
1011
  delta = JSON.parse(options[:delta_json].to_s)
1009
1012
  allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
1010
- applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
1011
- graph["revision"] += 1
1012
- stale = Array(graph["stale"]).any?
1013
- RequirementGraph.write_views!(root, graph, stale: stale)
1014
- RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1015
- DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
1016
- { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
1013
+ candidate = RequirementGraph.deep_clone(graph)
1014
+ applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
1015
+ candidate["revision"] += 1
1016
+ stale = Array(candidate["stale"]).any?
1017
+ if options[:write]
1018
+ RequirementGraph.write_views!(root, candidate, stale: stale)
1019
+ RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1020
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
1021
+ end
1022
+ { "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(candidate), "checkers" => RequirementGraph.checker_results(candidate), "views" => candidate["views"] }
1017
1023
  end
1018
1024
  when "import-test-cases"
1019
1025
  raise ArgumentError, "--write is required" unless options[:write]
@@ -9,6 +9,9 @@ require_relative "control_io"
9
9
  require_relative "phase_ownership"
10
10
  require_relative "requirement_graph"
11
11
  require_relative "architecture_feasibility"
12
+ require_relative "checker_registry"
13
+ require_relative "requirement_coverage"
14
+ require_relative "workflow_gate_contract"
12
15
 
13
16
  module WorkflowStateValidator
14
17
  REQUIRED_STATE_FIELDS = %w[
@@ -68,6 +71,8 @@ module WorkflowStateValidator
68
71
  )
69
72
 
70
73
  steps = Array(workflow["steps"])
74
+ validate_checker_registry(issues, workflow)
75
+ validate_workflow_gate_contract(issues, workflow)
71
76
  step_by_id = steps.to_h { |step| [step.fetch("id"), step] }
72
77
  step_ids = steps.map { |step| step.fetch("id") }
73
78
  completed_ids = Array(state["completed_steps"])
@@ -77,7 +82,7 @@ module WorkflowStateValidator
77
82
  validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
78
83
  validate_track_scope(issues, state, completed_ids)
79
84
  validate_dependencies(issues, step_by_id, completed_ids)
80
- validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
85
+ validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
81
86
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
82
87
  validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
83
88
  graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
@@ -264,13 +269,43 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
264
269
  end
265
270
  end
266
271
 
267
- def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
272
+ def validate_checker_registry(issues, workflow)
273
+ declared = workflow.dig("governance", "step_checkers")
274
+ return unless declared.is_a?(Hash)
275
+
276
+ declared.each do |step_id, checker_ids|
277
+ Array(checker_ids).each do |checker_id|
278
+ next if CheckerRegistry.known?(checker_id)
279
+
280
+ issues << issue(
281
+ "error",
282
+ "unknown_checker_id",
283
+ "Workflow references a checker that is not in the canonical checker registry",
284
+ step: step_id,
285
+ checker_id: checker_id
286
+ )
287
+ end
288
+ end
289
+ end
290
+
291
+ def validate_workflow_gate_contract(issues, workflow)
292
+ # GateContract validation is opt-in. Older/custom workflows intentionally
293
+ # use a smaller checkpoint shape and must keep the legacy control-plane
294
+ # behavior until they declare the contract version.
295
+ return unless workflow.dig("governance", "gate_contract_version").to_i >= 3
296
+
297
+ WorkflowGateContract.validate(workflow).each do |entry|
298
+ issues << issue("error", entry["code"], entry["message"], entry.reject { |key, _| %w[code message].include?(key) })
299
+ end
300
+ end
301
+
302
+ def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
268
303
  completed_ids.each do |step_id|
269
304
  step = step_by_id[step_id]
270
305
  next unless step
271
306
 
272
307
  validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint_step?(state, step_id))
273
- validate_meta_output(issues, root, state, step)
308
+ validate_meta_output(issues, root, state, step, workflow)
274
309
  end
275
310
  end
276
311
 
@@ -390,7 +425,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
390
425
  end
391
426
  end
392
427
 
393
- def validate_meta_output(issues, root, state, step)
428
+ def validate_meta_output(issues, root, state, step, workflow = nil)
394
429
  meta = step["meta_output"]
395
430
  return unless meta
396
431
 
@@ -411,6 +446,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
411
446
 
412
447
  validate_requirement_triage_gate(issues, step, data)
413
448
  validate_requirement_wayfinder_gate(issues, step, data)
449
+ validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
414
450
  validate_domain_modeling_gate(issues, step, data)
415
451
  validate_requirement_feasibility_review_gate(issues, step, data)
416
452
  validate_requirement_baseline_gate(issues, step, data)
@@ -427,6 +463,21 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
427
463
  issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
428
464
  end
429
465
 
466
+ def validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
467
+ return unless step["id"] == "requirement-wayfinder"
468
+ return unless workflow.nil? || workflow.dig("governance", "requirement_coverage_gate").to_s == "enforced"
469
+
470
+ result = RequirementCoverage.validate(root: root, feature: state.fetch("current_feature"))
471
+ return if result["verdict"] == "allow"
472
+
473
+ Array(result["errors"]).each do |entry|
474
+ issues << issue("error", entry["code"], entry["message"], step: step["id"], coverage: entry)
475
+ end
476
+ if result["verdict"] == "await_user"
477
+ issues << issue("error", "wayfinder_coverage_requires_decision", "Wayfinder coverage requires user review before completion", step: step["id"], counts: result["counts"])
478
+ end
479
+ end
480
+
430
481
  def validate_requirement_triage_gate(issues, step, data)
431
482
  return unless step["id"] == "requirement-triage"
432
483
 
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Validates the existing workflow topology as a GateContract source. This is
5
+ # intentionally declarative and read-only: it does not advance STATE or alter
6
+ # failure-branch behavior.
7
+ module WorkflowGateContract
8
+ CHECKPOINT_FIELDS = %w[gate_after producing_step required_outputs required_meta_output required_meta_fields prompt confirm_options skip_policy resume_behavior].freeze
9
+
10
+ module_function
11
+
12
+ def validate(workflow)
13
+ errors = []
14
+ checkpoints = workflow["checkpoints"]
15
+ unless checkpoints.is_a?(Hash)
16
+ return [error("CHECKPOINTS_SCHEMA_INVALID", "workflow checkpoints must be a mapping")]
17
+ end
18
+ checkpoints.each do |id, checkpoint|
19
+ unless checkpoint.is_a?(Hash)
20
+ errors << error("CHECKPOINT_SCHEMA_INVALID", "checkpoint must be a mapping", checkpoint: id)
21
+ next
22
+ end
23
+ CHECKPOINT_FIELDS.each do |field|
24
+ errors << error("CHECKPOINT_FIELD_MISSING", "checkpoint field is required", checkpoint: id, field: field) unless checkpoint.key?(field)
25
+ end
26
+ errors << error("CHECKPOINT_PRODUCER_MISMATCH", "gate_after must equal producing_step", checkpoint: id) if checkpoint["gate_after"].to_s != checkpoint["producing_step"].to_s
27
+ errors << error("CHECKPOINT_OUTPUTS_INVALID", "required_outputs must be an array", checkpoint: id) unless checkpoint["required_outputs"].is_a?(Array)
28
+ errors << error("CHECKPOINT_META_FIELDS_INVALID", "required_meta_fields must be an array", checkpoint: id) unless checkpoint["required_meta_fields"].is_a?(Array)
29
+ errors << error("CHECKPOINT_OPTIONS_INVALID", "confirm_options must include approve and needs_fix", checkpoint: id) unless Array(checkpoint["confirm_options"]).sort == %w[approve needs_fix]
30
+ end
31
+
32
+ Array(workflow.dig("governance", "enforced_checkpoints")).each do |id|
33
+ errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
34
+ end
35
+
36
+ Array(workflow["parallel_windows"]).each do |window|
37
+ errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window["id"]) unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
38
+ next unless window.is_a?(Hash)
39
+ errors << error("PARALLEL_WINDOW_CHECKPOINT_INVALID", "parallel join checkpoint is not defined", window: window["id"], checkpoint: window["checkpoint"]) if window["checkpoint"] && !checkpoints.key?(window["checkpoint"])
40
+ end
41
+
42
+ (workflow["failure_branches"] || {}).each do |source_step, branch|
43
+ resume = branch.is_a?(Hash) && branch["resume_after_success"]
44
+ unless resume.is_a?(Hash) && %w[flow step status checkpoint].all? { |field| resume.key?(field) }
45
+ errors << error("FAILURE_RETURN_TO_INVALID", "failure branch must declare flow, step, status and checkpoint", source_step: source_step)
46
+ next
47
+ end
48
+ errors << error("FAILURE_RETURN_CHECKPOINT_INVALID", "failure branch return checkpoint is not defined", source_step: source_step, checkpoint: resume["checkpoint"]) unless checkpoints.key?(resume["checkpoint"])
49
+ end
50
+ errors
51
+ end
52
+
53
+ def error(code, message, **extra)
54
+ { "code" => code, "message" => message }.merge(extra)
55
+ end
56
+ end
@@ -17,12 +17,15 @@ phase_scope:
17
17
  # files without governance_started_revision remain legacy_unattested and are
18
18
  # only reported until their next newly-started governed step.
19
19
  governance:
20
+ gate_contract_version: 3
20
21
  requirement_graph_gate: enforced
22
+ requirement_coverage_gate: enforced
21
23
  enforced_checkpoints:
22
24
  - CP-1
23
25
  - CP-API
24
26
  - CP-2
25
27
  - CP-3
28
+ - CP-4
26
29
  step_checkers:
27
30
  requirement-baseline:
28
31
  - requirement-graph-check
@@ -311,6 +314,7 @@ checkpoints:
311
314
  producing_step: run-acceptance-tests
312
315
  required_outputs:
313
316
  - ".planning/test-runs/{feature}/acceptance-results.json"
317
+ required_meta_output: ".planning/test-runs/{feature}/acceptance-results.meta.json"
314
318
  required_meta_fields:
315
319
  - acceptance_tc_passed
316
320
  - acceptance_tc_failed
@@ -365,6 +369,7 @@ steps:
365
369
  order: 6
366
370
  label: Requirement wayfinder
367
371
  skill: workflow-wayfinder
372
+ engine_evidence_gate: enforced
368
373
  depends_on:
369
374
  - requirement-triage
370
375
  inputs:
@@ -392,6 +397,7 @@ steps:
392
397
  order: 7
393
398
  label: Domain modeling
394
399
  skill: workflow-domain-modeling
400
+ engine_evidence_gate: enforced
395
401
  depends_on:
396
402
  - requirement-wayfinder
397
403
  inputs:
@@ -417,6 +423,7 @@ steps:
417
423
  order: 8
418
424
  label: Requirement feasibility review
419
425
  skill: requirement-feasibility-review
426
+ engine_evidence_gate: enforced
420
427
  depends_on:
421
428
  - domain-modeling
422
429
  inputs:
@@ -443,6 +450,7 @@ steps:
443
450
  order: 10
444
451
  label: Requirement baseline
445
452
  skill: workflow-requirement-baseline
453
+ engine_evidence_gate: enforced
446
454
  depends_on:
447
455
  - requirement-feasibility-review
448
456
  inputs:
@@ -478,6 +486,7 @@ steps:
478
486
  order: 15
479
487
  label: API contract
480
488
  skill: workflow-api-contract
489
+ engine_evidence_gate: enforced
481
490
  depends_on:
482
491
  - requirement-baseline
483
492
  inputs:
@@ -504,6 +513,7 @@ steps:
504
513
  order: 18
505
514
  label: Architecture feasibility
506
515
  skill: workflow-architecture-feasibility
516
+ engine_evidence_gate: enforced
507
517
  depends_on:
508
518
  - api-contract
509
519
  inputs:
@@ -535,6 +545,7 @@ steps:
535
545
  order: 20
536
546
  label: UI spec
537
547
  skill: workflow-ui-spec
548
+ engine_evidence_gate: enforced
538
549
  depends_on:
539
550
  - api-contract
540
551
  - architecture-feasibility
@@ -551,6 +562,7 @@ steps:
551
562
  order: 30
552
563
  label: Feature breakdown
553
564
  skill: workflow-feature-breakdown
565
+ engine_evidence_gate: enforced
554
566
  depends_on:
555
567
  - ui-spec
556
568
  inputs:
@@ -565,6 +577,7 @@ steps:
565
577
  order: 40
566
578
  label: Frontend tech spec
567
579
  skill: workflow-frontend-tech-spec
580
+ engine_evidence_gate: enforced
568
581
  depends_on:
569
582
  - feature-breakdown
570
583
  - api-contract
@@ -598,6 +611,7 @@ steps:
598
611
  order: 50
599
612
  label: Backend tech spec
600
613
  skill: workflow-backend-tech-spec
614
+ engine_evidence_gate: enforced
601
615
  depends_on:
602
616
  - api-contract
603
617
  - architecture-feasibility
@@ -625,6 +639,7 @@ steps:
625
639
  order: 55
626
640
  label: API call guide
627
641
  skill: workflow-api-call-guide
642
+ engine_evidence_gate: enforced
628
643
  depends_on:
629
644
  - api-contract
630
645
  - backend-tech-spec
@@ -649,6 +664,7 @@ steps:
649
664
  order: 56
650
665
  label: API supplement
651
666
  skill: workflow-api-supplement
667
+ engine_evidence_gate: enforced
652
668
  depends_on:
653
669
  - frontend-tech-spec
654
670
  - api-contract
@@ -672,6 +688,7 @@ steps:
672
688
  order: 58
673
689
  label: Integration verify
674
690
  skill: workflow-integration-verify
691
+ engine_evidence_gate: enforced
675
692
  depends_on:
676
693
  - api-call-guide
677
694
  - frontend-tech-spec
@@ -702,6 +719,7 @@ steps:
702
719
  order: 60
703
720
  label: Change plan
704
721
  skill: workflow-change-plan
722
+ engine_evidence_gate: enforced
705
723
  depends_on:
706
724
  - acceptance-test-design
707
725
  inputs:
@@ -735,6 +753,7 @@ steps:
735
753
  order: 59
736
754
  label: Acceptance test design
737
755
  skill: workflow-acceptance-test-design
756
+ engine_evidence_gate: enforced
738
757
  depends_on:
739
758
  - integration-verify
740
759
  inputs:
@@ -774,6 +793,7 @@ steps:
774
793
  order: 70
775
794
  label: Phase structure
776
795
  action: initialize-phase-structure
796
+ engine_evidence_gate: enforced
777
797
  depends_on:
778
798
  - change-plan
779
799
  inputs:
@@ -791,6 +811,7 @@ steps:
791
811
  order: 80
792
812
  label: Execute
793
813
  skill: workflow-execute-phase
814
+ engine_evidence_gate: enforced
794
815
  depends_on:
795
816
  - phase-structure
796
817
  inputs:
@@ -835,6 +856,7 @@ steps:
835
856
  order: 85
836
857
  label: Code Review
837
858
  skill: code-review
859
+ engine_evidence_gate: enforced
838
860
  depends_on:
839
861
  - code-index-refresh
840
862
  inputs:
@@ -849,6 +871,7 @@ steps:
849
871
  order: 86
850
872
  label: Generate mainflow cases
851
873
  skill: workflow-generate-mainflow-cases
874
+ engine_evidence_gate: enforced
852
875
  depends_on:
853
876
  - code-index-refresh
854
877
  inputs:
@@ -887,6 +910,7 @@ steps:
887
910
  order: 88
888
911
  label: Run frozen acceptance tests
889
912
  skill: workflow-run-acceptance-tests
913
+ engine_evidence_gate: enforced
890
914
  depends_on:
891
915
  - code-review
892
916
  inputs:
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.38";
2
+ export const PRODUCT_VERSION = "0.1.39";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5