@guanlin4924/devflow-pi 0.1.38 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +178 -8
  3. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +157 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +131 -5
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
  18. package/src/constants.ts +1 -1
  19. package/src/runtime.ts +8 -2
@@ -0,0 +1,31 @@
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
+ cross-track-consistency-check
11
+ ].freeze
12
+ EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
13
+
14
+ module_function
15
+
16
+ def known?(id)
17
+ (DECLARED + EMBEDDED).include?(id.to_s)
18
+ end
19
+
20
+ def kind(id)
21
+ return "declared" if DECLARED.include?(id.to_s)
22
+ return "embedded" if EMBEDDED.include?(id.to_s)
23
+ nil
24
+ end
25
+
26
+ def validate!(ids)
27
+ unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
28
+ raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
29
+ true
30
+ end
31
+ end
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "yaml"
7
+
8
+ options = { feature: nil, json: false }
9
+ OptionParser.new do |opts|
10
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
11
+ opts.on("--json") { options[:json] = true }
12
+ end.parse!
13
+
14
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
15
+ feature = options[:feature].to_s
16
+ raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
17
+
18
+ root = Dir.pwd
19
+ base = File.join(root, ".planning", "features", feature)
20
+ files = {
21
+ "api_contract" => File.join(base, "api-contract.md"),
22
+ "api_call_guide" => File.join(base, "api-call-guide.md"),
23
+ "frontend_spec" => File.join(base, "前端技术方案.md"),
24
+ "backend_spec" => File.join(base, "后端技术方案.md"),
25
+ "api_supplement" => File.join(base, "api-supplement-requests.md"),
26
+ }
27
+ errors = []
28
+ missing = files.select { |_, path| !File.file?(path) }.keys
29
+ errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
30
+
31
+ read = lambda do |key|
32
+ path = files.fetch(key)
33
+ File.file?(path) ? File.read(path) : ""
34
+ end
35
+ contract = read.call("api_contract")
36
+ guide = read.call("api_call_guide")
37
+ frontend = read.call("frontend_spec")
38
+
39
+ # Endpoint references are deliberately extracted from the authoritative API
40
+ # contract and checked for textual closure in both consumers. This is a
41
+ # deterministic reference-closure check; an AI integration summary is not used.
42
+ endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
43
+ endpoints = contract.scan(endpoint_pattern).flatten.uniq
44
+ unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
45
+ unless unreferenced.empty?
46
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
47
+ end
48
+
49
+ contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
50
+ {
51
+ "api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
52
+ "frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
53
+ }.each do |consumer, references|
54
+ ghosts = references.reject { |endpoint| contract_set[endpoint] }
55
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
56
+ end
57
+
58
+ supplement = read.call("api_supplement")
59
+ supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
60
+ supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
61
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
62
+ supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
63
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
64
+
65
+ meta_path = File.join(base, "api-call-guide.meta.json")
66
+ if File.file?(meta_path)
67
+ begin
68
+ meta = JSON.parse(File.read(meta_path))
69
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
70
+ pending = meta["pending_items"]
71
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
72
+ rescue JSON::ParserError => error
73
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
74
+ end
75
+ end
76
+
77
+ supplement_meta = File.join(base, "api-supplement-requests.meta.json")
78
+ if File.file?(supplement_meta)
79
+ begin
80
+ meta = JSON.parse(File.read(supplement_meta))
81
+ unresolved = meta["unresolved_supplement_count"]
82
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
83
+ rescue JSON::ParserError => error
84
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
85
+ end
86
+ end
87
+
88
+ result = {
89
+ "ok" => errors.empty?,
90
+ "checker_id" => "cross-track-consistency-check",
91
+ "errors" => errors,
92
+ "evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
93
+ }
94
+ puts(options[:json] ? JSON.generate(result) : result.to_yaml)
@@ -14,7 +14,7 @@ POLICY = {
14
14
  "requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
15
15
  "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
16
16
  "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
17
- "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
17
+ "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
18
18
  "acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
19
19
  "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
20
20
  "execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
@@ -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,157 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "gate_result"
6
+ require_relative "gate_contract"
7
+ require_relative "checker_registry"
8
+ require_relative "parallel_run"
9
+
10
+ module GateRuntime
11
+ WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
12
+
13
+ module_function
14
+
15
+ def validate_contract!(contract)
16
+ GateContract.validate!(contract)
17
+ CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
18
+ contract
19
+ end
20
+
21
+ def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, branch_id: nil, attempt: nil, branch_revision: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
22
+ validate_contract!(contract)
23
+ gate_id = contract.fetch("gate_id")
24
+ step_id = contract.fetch("step_id")
25
+ base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
26
+ 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
27
+ unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
28
+ return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
29
+ end
30
+ if workflow_state["parallel_run_ref"]
31
+ branch_error = validate_parallel_branch(
32
+ contract: contract,
33
+ workflow_state: workflow_state,
34
+ current_revision: current_revision,
35
+ track: track,
36
+ branch_id: branch_id,
37
+ attempt: attempt,
38
+ branch_revision: branch_revision,
39
+ evidence: evidence,
40
+ root: root,
41
+ transition_kind: transition_kind,
42
+ )
43
+ return GateResult.block(**base, **branch_error) if branch_error
44
+ end
45
+ unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch parallel_join].include?(transition_kind)
46
+ allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
47
+ if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
48
+ return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
49
+ end
50
+ allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
51
+ previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
52
+ if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
53
+ return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
54
+ end
55
+ if root
56
+ missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
57
+ return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
58
+ if check_outputs
59
+ missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
60
+ return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
61
+ end
62
+ end
63
+ end
64
+ if transition_kind == "confirm_checkpoint"
65
+ checkpoint_id = workflow_state["checkpoint"].to_s
66
+ checkpoint = contract.dig("topology", "checkpoint")
67
+ checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
68
+ unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
69
+ return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
70
+ end
71
+ unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
72
+ return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
73
+ end
74
+ end
75
+ GateResult.allow(**base)
76
+ rescue KeyError, ArgumentError => e
77
+ 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)
78
+ end
79
+
80
+ def validate_parallel_branch(contract:, workflow_state:, current_revision:, track:, branch_id:, attempt:, branch_revision:, evidence:, root:, transition_kind:)
81
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires root" } unless root
82
+
83
+ record = ParallelRun.load(root, workflow_state.fetch("current_feature"))
84
+ return { code: "PARALLEL_RUN_NOT_FOUND", message: "parallel_run_ref does not resolve to a run record" } unless record
85
+ return { code: "PARALLEL_RUN_REF_MISMATCH", message: "STATE parallel_run_ref does not match run record" } unless record["run_id"].to_s == workflow_state["parallel_run_ref"].to_s
86
+ bound_revision = Integer(record.fetch("bound_state_revision"))
87
+ return { code: "PARALLEL_STATE_REVISION_MISMATCH", message: "parallel run is bound to a different STATE revision", details: { expected_state_revision: bound_revision, actual_state_revision: current_revision } } unless bound_revision == current_revision.to_i
88
+
89
+ if transition_kind.to_s == "parallel_join"
90
+ branches = Array(record["branches"])
91
+ return { code: "PARALLEL_JOIN_INCOMPLETE", message: "parallel join requires every branch to succeed", details: { incomplete: branches.reject { |item| item["status"].to_s == "succeeded" }.map { |item| item["branch_id"] } } } unless branches.any? && branches.all? { |item| item["status"].to_s == "succeeded" }
92
+ return nil
93
+ end
94
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires branch_id" } unless branch_id
95
+
96
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
97
+ return { code: "PARALLEL_BRANCH_NOT_FOUND", message: "branch is not registered in the parallel run", details: { branch_id: branch_id } } unless branch
98
+ return { code: "PARALLEL_STEP_MISMATCH", message: "branch is bound to a different workflow step", details: { branch_step: branch["workflow_step"], gate_step: contract["step_id"] } } unless branch["workflow_step"].to_s == contract["step_id"].to_s
99
+ expected_track = branch["track"].to_s.sub(/\Atrack_/, "").upcase
100
+ return { code: "PARALLEL_TRACK_MISMATCH", message: "branch track is not the requested track", details: { branch_track: expected_track, requested_track: track } } unless track.to_s.empty? || expected_track.empty? || expected_track == track.to_s
101
+ return { code: "PARALLEL_BRANCH_TERMINAL", message: "terminal branch cannot pass a gate; retry must create a new attempt" } if ParallelRun::TERMINAL_BRANCH_STATUSES.include?(branch["status"].to_s) && transition_kind.to_s != "parallel_branch_retry"
102
+ return { code: "PARALLEL_ATTEMPT_MISMATCH", message: "branch attempt does not match the current run record" } if attempt && Integer(branch.fetch("attempt", 0)) != attempt.to_i
103
+ return { code: "PARALLEL_BRANCH_REVISION_MISMATCH", message: "branch revision does not match the current run record" } if branch_revision && Integer(branch.fetch("branch_revision", 0)) != branch_revision.to_i
104
+
105
+ predecessor_ids = Array(contract.dig("topology", "allowed_predecessors"))
106
+ completed = Array(workflow_state["completed_steps"]).map(&:to_s)
107
+ completed.concat(Array(record["branches"]).select { |item| item["status"].to_s == "succeeded" }.map { |item| item["workflow_step"].to_s })
108
+ missing_predecessors = predecessor_ids.map(&:to_s).reject { |step| completed.include?(step) }
109
+ return { code: "PARALLEL_PREDECESSOR_INCOMPLETE", message: "parallel branch predecessor is incomplete", details: { missing: missing_predecessors } } unless missing_predecessors.empty?
110
+
111
+ if transition_kind.to_s == "parallel_branch_complete"
112
+ required = %w[branch_id attempt branch_revision]
113
+ missing = required.reject { |field| evidence.key?(field) }
114
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "branch completion requires independent evidence binding", details: { missing: missing } } unless missing.empty?
115
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "branch evidence does not match current branch", details: { branch_id: branch_id } } unless evidence["branch_id"].to_s == branch_id.to_s && evidence["attempt"].to_i == branch.fetch("attempt", 0).to_i && evidence["branch_revision"].to_i == branch.fetch("branch_revision", 0).to_i
116
+ evidence_path = evidence["evidence_path"].to_s
117
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "Core machine evidence file is required" } if evidence_path.empty? || !File.file?(File.expand_path(evidence_path, root))
118
+ begin
119
+ pack = JSON.parse(File.read(File.expand_path(evidence_path, root)))
120
+ binding = pack.dig("evidence", "parallel_binding")
121
+ expected = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch.fetch("attempt", 0), "branch_revision" => branch.fetch("branch_revision", 0), "state_revision" => current_revision.to_i }
122
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence binding does not match current branch", details: { expected: expected, actual: binding } } unless binding.is_a?(Hash) && expected.all? { |key, value| binding[key].to_s == value.to_s }
123
+ rescue JSON::ParserError, Errno::ENOENT
124
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence file is not valid JSON" }
125
+ end
126
+ end
127
+ nil
128
+ rescue KeyError, ArgumentError, TypeError
129
+ { code: "PARALLEL_RECORD_INVALID", message: "parallel run or branch record is invalid" }
130
+ end
131
+
132
+ def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
133
+ boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
134
+ return boundary unless boundary["verdict"] == "allow"
135
+ result = checker.call
136
+ raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
137
+ result.merge(
138
+ "gate_id" => result["gate_id"] || boundary["gate_id"],
139
+ "step_id" => result["step_id"] || boundary["step_id"],
140
+ "evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
141
+ "revisions" => boundary.fetch("revisions", {}).merge(result.fetch("revisions", {}))
142
+ )
143
+ end
144
+
145
+ def required_paths(entries, root, state)
146
+ Array(entries).each_with_object([]) do |entry, missing|
147
+ raw = entry.is_a?(Hash) ? entry["path"] : entry
148
+ next if raw.to_s.empty?
149
+ relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
150
+ absolute = File.expand_path(relative, root)
151
+ root_real = File.realpath(root)
152
+ actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
153
+ next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
154
+ missing << relative
155
+ end
156
+ end
157
+ end
@@ -191,6 +191,9 @@ def active_parallel_window(workflow, state, root, step_by_id, completed_ids, pen
191
191
  step = step_by_id[step_id]
192
192
  pending_ids.include?(step_id) && step && dependencies_satisfied?(step, completed_ids)
193
193
  end
194
+ # A single runnable branch is an intentional serial fallback (for example
195
+ # when active_track limits the window to one track), not a missing window.
196
+ # Only a genuinely multi-branch runnable set enters the parallel scheduler.
194
197
  next unless runnable_ids.length > 1
195
198
 
196
199
  policy = window["execution_policy"] || {}
@@ -10,14 +10,19 @@ 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
- options = { feature: nil, step: nil, contract: nil, write: false, json: false }
15
+ options = { feature: nil, step: nil, contract: nil, write: false, json: false, branch_id: nil, attempt: nil, branch_revision: nil, lock_held: false }
15
16
  OptionParser.new do |o|
16
17
  o.on("--feature NAME") { |v| options[:feature] = v }
17
18
  o.on("--step ID") { |v| options[:step] = v }
18
19
  o.on("--contract PATH") { |v| options[:contract] = v }
19
20
  o.on("--write") { options[:write] = true }
20
21
  o.on("--json") { options[:json] = true }
22
+ o.on("--parallel-branch-id ID") { |v| options[:branch_id] = v }
23
+ o.on("--parallel-attempt N", Integer) { |v| options[:attempt] = v }
24
+ o.on("--parallel-branch-revision N", Integer) { |v| options[:branch_revision] = v }
25
+ o.on("--lock-held") { options[:lock_held] = true }
21
26
  end.parse!
22
27
  abort "feature, step and --write are required" unless options[:feature] && options[:step] && options[:write]
23
28
  abort "invalid feature" unless RequirementGraph.valid_feature?(options[:feature])
@@ -33,6 +38,81 @@ def interpolate(path, state)
33
38
  path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
34
39
  end
35
40
 
41
+ def file_evidence(root, relative)
42
+ absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact)
43
+ return { "path" => relative, "exists" => false } unless File.file?(absolute)
44
+
45
+ { "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
46
+ end
47
+
48
+ def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results, binding = nil, inputs = [])
49
+ source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
50
+ authority = source_nodes.map do |node|
51
+ evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
52
+ {
53
+ "source_id" => node["id"], "authority" => evidence["authority"], "path" => evidence["path"] || evidence["ref"],
54
+ "fingerprint" => evidence["sha256"] || evidence["source_document_fingerprint"]
55
+ }.compact
56
+ end
57
+
58
+ # The harness may publish an append-only event log beside the machine pack.
59
+ # Absence is represented as unavailable, never as false/zero, so the pack
60
+ # cannot manufacture proof that an engine did not invoke AI.
61
+ binding_attempt = binding && binding["attempt"]
62
+ event_name = binding_attempt.nil? ? "#{step}.engine-events.jsonl" : "#{step}.attempt-#{binding_attempt}.engine-events.jsonl"
63
+ event_path = File.join(".planning", "evidence", feature, event_name)
64
+ event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
65
+ events = if File.file?(event_absolute)
66
+ File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
67
+ next if line.strip.empty?
68
+
69
+ begin
70
+ parsed << JSON.parse(line)
71
+ rescue JSON::ParserError
72
+ # A malformed event is not evidence of a clean execution;
73
+ # retain the valid events and let the collector expose the
74
+ # observed subset without inventing a zero count.
75
+ end
76
+ end
77
+ else
78
+ nil
79
+ end
80
+ ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
81
+ dispatched_events = events ? events.select { |event| event["event"] == "engine_dispatched" } : []
82
+ ai_measurement_observed = !ai_events.empty?
83
+
84
+ {
85
+ "collector" => "core.machine_evidence/v1",
86
+ "state" => { "revision" => state["revision"], "step" => step },
87
+ "parallel_binding" => binding,
88
+ "input_fingerprints" => inputs.select { |item| item["exists"] }.map { |item| item["sha256"] },
89
+ "output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
90
+ "source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
91
+ "candidate_binding" => {
92
+ "status" => "observed",
93
+ "graph_revision" => graph["revision"],
94
+ "test_case_ids" => Array(graph["nodes"]).each_with_object([]) { |node, ids| ids << node["id"] if node.is_a?(Hash) && node["kind"] == "test_case" }
95
+ },
96
+ "structured_execution" => {
97
+ "status" => "observed",
98
+ "required_files" => files,
99
+ "checker_ids" => checker_results.map { |item| item["id"] }
100
+ },
101
+ "engine_evidence" => {
102
+ "collector" => events ? "harness.engine-events/v1" : "unavailable",
103
+ "event_log" => event_path,
104
+ # engine_dispatched proves that the harness dispatched work, but it does
105
+ # not prove how many model calls the child session made. Keep unknown as
106
+ # nil instead of manufacturing false/zero values for branch evidence.
107
+ "ai_runtime_initialized" => ai_measurement_observed ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
108
+ "ai_invocation_count" => ai_measurement_observed ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
109
+ "engine_dispatched_count" => events ? dispatched_events.length : nil,
110
+ "ai_capable_engine_dispatched" => events ? dispatched_events.any? { |event| event["ai_capable"] == true } : nil,
111
+ "evidence_status" => events ? "observed" : "unavailable"
112
+ }
113
+ }
114
+ end
115
+
36
116
  def configured_checkers(workflow, step_id)
37
117
  explicit = workflow.dig("governance", "step_checkers", step_id)
38
118
  checkers = if explicit
@@ -92,13 +172,24 @@ rescue JSON::ParserError => error
92
172
  end
93
173
 
94
174
  root = Dir.pwd
95
- lock = DevflowControlIO.acquire_project_lock(root)
175
+ lock = options[:lock_held] ? nil : DevflowControlIO.acquire_project_lock(root)
96
176
  begin
97
177
  state = state_front_matter(root)
98
- unless state["current_feature"] == options[:feature] && state["current_step"] == options[:step]
178
+ binding = nil
179
+ branch = nil
180
+ if options[:branch_id]
181
+ require_relative "parallel_run"
182
+ record = ParallelRun.load(root, options[:feature])
183
+ branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == options[:branch_id].to_s }
184
+ raise "parallel branch not found" unless record && branch
185
+ raise "parallel branch attempt mismatch" unless options[:attempt].nil? || Integer(branch.fetch("attempt", 0)) == options[:attempt]
186
+ raise "parallel branch revision mismatch" unless options[:branch_revision].nil? || Integer(branch.fetch("branch_revision", 0)) == options[:branch_revision]
187
+ binding = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch["attempt"], "branch_revision" => branch["branch_revision"], "state_revision" => state["revision"] }
188
+ end
189
+ unless state["current_feature"] == options[:feature] && (state["current_step"] == options[:step] || branch)
99
190
  result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
100
191
  puts JSON.pretty_generate(result)
101
- exit 0
192
+ exit 1
102
193
  end
103
194
  workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
104
195
  workflow_raw = File.read(workflow_path)
@@ -117,10 +208,12 @@ begin
117
208
  { "path" => relative, "required" => true, "exists" => exists, "bytes" => exists ? File.size(absolute) : 0,
118
209
  "sha256" => exists ? Digest::SHA256.file(absolute).hexdigest : nil }
119
210
  end.compact
211
+ input_files = Array(step["inputs"]).map { |input| file_evidence(root, interpolate(input.to_s, state)) }
120
212
 
121
213
  graph, = RequirementGraph.load_graph(root, options[:feature])
122
214
  checkers = RequirementGraph.checker_results(graph)
123
215
  selected = configured_checkers(workflow, options[:step])
216
+ CheckerRegistry.validate!(selected)
124
217
  gate = workflow.dig("governance", "requirement_graph_gate").to_s
125
218
  enforced = gate == "enforced"
126
219
  checker_results = selected.map do |id|
@@ -133,6 +226,7 @@ begin
133
226
  end
134
227
  graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
135
228
  checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
229
+ engine_evidence_gate = (step["engine_evidence_gate"] || workflow.dig("governance", "engine_evidence_gate")).to_s
136
230
  contract_hash = nil
137
231
  if options[:contract]
138
232
  contract_path = File.expand_path(options[:contract], root)
@@ -140,22 +234,31 @@ begin
140
234
  contract_hash = Digest::SHA256.file(contract_path).hexdigest
141
235
  end
142
236
  node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
237
+ independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results, binding, input_files)
238
+ engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
239
+ evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
143
240
  pack = {
144
241
  "schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
145
242
  "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,
243
+ "run_id" => binding && binding["run_id"], "branch_id" => binding && binding["branch_id"],
244
+ "attempt" => binding && binding["attempt"], "branch_revision" => binding && binding["branch_revision"],
245
+ "input_fingerprints" => input_files.select { |item| item["exists"] }.map { |item| item["sha256"] },
246
+ "output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
247
+ "contract_hash" => contract_hash, "files" => files, "input_files" => input_files, "checkers" => checker_results, "evidence" => independent,
147
248
  "counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
148
249
  "stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
149
250
  "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,
251
+ "complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
252
+ "evidence_complete" => evidence_complete,
151
253
  "gate_mode" => gate.empty? ? "observe" : gate
152
254
  }.compact
153
- destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}.machine.json")
255
+ suffix = binding ? ".attempt-#{binding["attempt"]}.branch-#{Digest::SHA256.hexdigest(binding["branch_id"].to_s)[0, 12]}" : ""
256
+ destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}#{suffix}.machine.json")
154
257
  DevflowControlIO.atomic_write(destination, JSON.pretty_generate(pack) + "\n")
155
- result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results }
258
+ result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results, "parallel_binding" => binding }
156
259
  rescue StandardError => error
157
260
  result = { "ok" => false, "reason" => "machine_evidence_failed", "message" => error.message }
158
261
  ensure
159
- DevflowControlIO.release_lock(lock)
262
+ DevflowControlIO.release_lock(lock) if lock
160
263
  end
161
264
  puts JSON.pretty_generate(result)