@guanlin4924/devflow-pi 0.1.39 → 0.1.47

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 (53) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  45. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  46. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
  47. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
  48. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  49. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  50. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  52. package/src/constants.ts +1 -1
  53. package/src/runtime.ts +61 -24
@@ -1,22 +1,29 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "json"
4
5
  require_relative "gate_result"
5
6
  require_relative "gate_contract"
6
7
  require_relative "checker_registry"
8
+ require_relative "parallel_run"
9
+ require_relative "business_model_gate"
7
10
 
8
11
  module GateRuntime
9
- WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
12
+ WORKFLOW_STATUSES = %w[not_started in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
10
13
 
11
14
  module_function
12
15
 
16
+ def evaluate_business_model(violations:, transition_kind:, gate_id: "business-model-closure", step_id: "business-model")
17
+ BusinessModelGate.evaluate(violations: violations, transition_kind: transition_kind, gate_id: gate_id, step_id: step_id)
18
+ end
19
+
13
20
  def validate_contract!(contract)
14
21
  GateContract.validate!(contract)
15
22
  CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
16
23
  contract
17
24
  end
18
25
 
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)
26
+ 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)
20
27
  validate_contract!(contract)
21
28
  gate_id = contract.fetch("gate_id")
22
29
  step_id = contract.fetch("step_id")
@@ -25,18 +32,34 @@ module GateRuntime
25
32
  unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
26
33
  return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
27
34
  end
28
- unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch].include?(transition_kind)
35
+ if workflow_state["parallel_run_ref"]
36
+ branch_error = validate_parallel_branch(
37
+ contract: contract,
38
+ workflow_state: workflow_state,
39
+ current_revision: current_revision,
40
+ track: track,
41
+ branch_id: branch_id,
42
+ attempt: attempt,
43
+ branch_revision: branch_revision,
44
+ evidence: evidence,
45
+ root: root,
46
+ transition_kind: transition_kind,
47
+ )
48
+ return GateResult.block(**base, **branch_error) if branch_error
49
+ end
50
+ unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch parallel_join].include?(transition_kind)
29
51
  allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
30
52
  if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
31
53
  return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
32
54
  end
33
55
  allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
56
+ allowed_intervening = Array(contract.dig("topology", "allowed_intervening_predecessors"))
34
57
  previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
35
- if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
58
+ if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous) && !allowed_intervening.include?(previous)
36
59
  return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
37
60
  end
38
61
  if root
39
- missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
62
+ missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state, allow_missing_collections: true)
40
63
  return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
41
64
  if check_outputs
42
65
  missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
@@ -60,6 +83,58 @@ module GateRuntime
60
83
  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
84
  end
62
85
 
86
+ def validate_parallel_branch(contract:, workflow_state:, current_revision:, track:, branch_id:, attempt:, branch_revision:, evidence:, root:, transition_kind:)
87
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires root" } unless root
88
+
89
+ record = ParallelRun.load(root, workflow_state.fetch("current_feature"))
90
+ return { code: "PARALLEL_RUN_NOT_FOUND", message: "parallel_run_ref does not resolve to a run record" } unless record
91
+ 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
92
+ bound_revision = Integer(record.fetch("bound_state_revision"))
93
+ 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
94
+
95
+ if transition_kind.to_s == "parallel_join"
96
+ branches = Array(record["branches"])
97
+ 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" }
98
+ return nil
99
+ end
100
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires branch_id" } unless branch_id
101
+
102
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
103
+ return { code: "PARALLEL_BRANCH_NOT_FOUND", message: "branch is not registered in the parallel run", details: { branch_id: branch_id } } unless branch
104
+ 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
105
+ expected_track = branch["track"].to_s.sub(/\Atrack_/, "").upcase
106
+ 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
107
+ 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"
108
+ 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
109
+ 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
110
+
111
+ predecessor_ids = Array(contract.dig("topology", "allowed_predecessors"))
112
+ completed = Array(workflow_state["completed_steps"]).map(&:to_s)
113
+ completed.concat(Array(record["branches"]).select { |item| item["status"].to_s == "succeeded" }.map { |item| item["workflow_step"].to_s })
114
+ missing_predecessors = predecessor_ids.map(&:to_s).reject { |step| completed.include?(step) }
115
+ return { code: "PARALLEL_PREDECESSOR_INCOMPLETE", message: "parallel branch predecessor is incomplete", details: { missing: missing_predecessors } } unless missing_predecessors.empty?
116
+
117
+ if transition_kind.to_s == "parallel_branch_complete"
118
+ required = %w[branch_id attempt branch_revision]
119
+ missing = required.reject { |field| evidence.key?(field) }
120
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "branch completion requires independent evidence binding", details: { missing: missing } } unless missing.empty?
121
+ 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
122
+ evidence_path = evidence["evidence_path"].to_s
123
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "Core machine evidence file is required" } if evidence_path.empty? || !File.file?(File.expand_path(evidence_path, root))
124
+ begin
125
+ pack = JSON.parse(File.read(File.expand_path(evidence_path, root)))
126
+ binding = pack.dig("evidence", "parallel_binding")
127
+ 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 }
128
+ 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 }
129
+ rescue JSON::ParserError, Errno::ENOENT
130
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence file is not valid JSON" }
131
+ end
132
+ end
133
+ nil
134
+ rescue KeyError, ArgumentError, TypeError
135
+ { code: "PARALLEL_RECORD_INVALID", message: "parallel run or branch record is invalid" }
136
+ end
137
+
63
138
  def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
64
139
  boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
65
140
  return boundary unless boundary["verdict"] == "allow"
@@ -73,13 +148,16 @@ module GateRuntime
73
148
  )
74
149
  end
75
150
 
76
- def required_paths(entries, root, state)
151
+ def required_paths(entries, root, state, allow_missing_collections: false)
77
152
  Array(entries).each_with_object([]) do |entry, missing|
78
153
  raw = entry.is_a?(Hash) ? entry["path"] : entry
79
154
  next if raw.to_s.empty?
80
155
  relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
81
156
  absolute = File.expand_path(relative, root)
82
157
  root_real = File.realpath(root)
158
+ root_expanded = File.expand_path(root)
159
+ inside_root = absolute.start_with?("#{root_expanded}#{File::SEPARATOR}")
160
+ next if allow_missing_collections && raw.to_s.end_with?(File::SEPARATOR) && inside_root && !File.exist?(absolute)
83
161
  actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
84
162
  next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
85
163
  missing << relative
@@ -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"] || {}
@@ -12,13 +12,17 @@ require_relative "control_io"
12
12
  require_relative "requirement_graph"
13
13
  require_relative "checker_registry"
14
14
 
15
- 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 }
16
16
  OptionParser.new do |o|
17
17
  o.on("--feature NAME") { |v| options[:feature] = v }
18
18
  o.on("--step ID") { |v| options[:step] = v }
19
19
  o.on("--contract PATH") { |v| options[:contract] = v }
20
20
  o.on("--write") { options[:write] = true }
21
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 }
22
26
  end.parse!
23
27
  abort "feature, step and --write are required" unless options[:feature] && options[:step] && options[:write]
24
28
  abort "invalid feature" unless RequirementGraph.valid_feature?(options[:feature])
@@ -41,7 +45,7 @@ def file_evidence(root, relative)
41
45
  { "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
42
46
  end
43
47
 
44
- def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results)
48
+ def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results, binding = nil, inputs = [])
45
49
  source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
46
50
  authority = source_nodes.map do |node|
47
51
  evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
@@ -54,7 +58,9 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
54
58
  # The harness may publish an append-only event log beside the machine pack.
55
59
  # Absence is represented as unavailable, never as false/zero, so the pack
56
60
  # cannot manufacture proof that an engine did not invoke AI.
57
- event_path = File.join(".planning", "evidence", feature, "#{step}.engine-events.jsonl")
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)
58
64
  event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
59
65
  events = if File.file?(event_absolute)
60
66
  File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
@@ -72,10 +78,15 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
72
78
  nil
73
79
  end
74
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?
75
83
 
76
84
  {
77
85
  "collector" => "core.machine_evidence/v1",
78
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"] },
79
90
  "source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
80
91
  "candidate_binding" => {
81
92
  "status" => "observed",
@@ -90,8 +101,13 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
90
101
  "engine_evidence" => {
91
102
  "collector" => events ? "harness.engine-events/v1" : "unavailable",
92
103
  "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,
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,
95
111
  "evidence_status" => events ? "observed" : "unavailable"
96
112
  }
97
113
  }
@@ -156,10 +172,21 @@ rescue JSON::ParserError => error
156
172
  end
157
173
 
158
174
  root = Dir.pwd
159
- lock = DevflowControlIO.acquire_project_lock(root)
175
+ lock = options[:lock_held] ? nil : DevflowControlIO.acquire_project_lock(root)
160
176
  begin
161
177
  state = state_front_matter(root)
162
- 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)
163
190
  result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
164
191
  puts JSON.pretty_generate(result)
165
192
  exit 1
@@ -181,6 +208,7 @@ begin
181
208
  { "path" => relative, "required" => true, "exists" => exists, "bytes" => exists ? File.size(absolute) : 0,
182
209
  "sha256" => exists ? Digest::SHA256.file(absolute).hexdigest : nil }
183
210
  end.compact
211
+ input_files = Array(step["inputs"]).map { |input| file_evidence(root, interpolate(input.to_s, state)) }
184
212
 
185
213
  graph, = RequirementGraph.load_graph(root, options[:feature])
186
214
  checkers = RequirementGraph.checker_results(graph)
@@ -206,13 +234,17 @@ begin
206
234
  contract_hash = Digest::SHA256.file(contract_path).hexdigest
207
235
  end
208
236
  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)
237
+ independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results, binding, input_files)
210
238
  engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
211
239
  evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
212
240
  pack = {
213
241
  "schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
214
242
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
215
- "contract_hash" => contract_hash, "files" => files, "checkers" => checker_results, "evidence" => independent,
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,
216
248
  "counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
217
249
  "stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
218
250
  "view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
@@ -220,12 +252,13 @@ begin
220
252
  "evidence_complete" => evidence_complete,
221
253
  "gate_mode" => gate.empty? ? "observe" : gate
222
254
  }.compact
223
- 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")
224
257
  DevflowControlIO.atomic_write(destination, JSON.pretty_generate(pack) + "\n")
225
- 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 }
226
259
  rescue StandardError => error
227
260
  result = { "ok" => false, "reason" => "machine_evidence_failed", "message" => error.message }
228
261
  ensure
229
- DevflowControlIO.release_lock(lock)
262
+ DevflowControlIO.release_lock(lock) if lock
230
263
  end
231
264
  puts JSON.pretty_generate(result)