@guanlin4924/devflow-pi 0.1.86 → 0.1.89

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 (46) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +199 -9
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +15 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +49 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_adapter.rb +83 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +19 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checkpoint_meta.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +27 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +32 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +44 -41
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +5 -4
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +15 -4
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +15 -9
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_preflight.py +238 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +234 -32
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +63 -11
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +5 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +40 -31
  20. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +11 -5
  21. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +6 -6
  22. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +16 -1
  23. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +3 -1
  24. package/plugins/devflow-quality/agents/code-reviewer.md +7 -1
  25. package/plugins/devflow-quality/skills/code-review/SKILL.md +10 -0
  26. package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +37 -0
  27. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/SKILL.md +327 -0
  28. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/assets/output-template.md +213 -0
  29. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/bug-rules.md +9 -0
  30. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/coverage-checklist.md +63 -0
  31. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/exception-checklist.md +37 -0
  32. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/api-test.md +58 -0
  33. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/boundary-test.md +151 -0
  34. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/common-test.md +49 -0
  35. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/compatibility-test.md +105 -0
  36. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/data-test.md +67 -0
  37. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/exception-test.md +109 -0
  38. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/function-test.md +65 -0
  39. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/integration-test.md +36 -0
  40. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/performance-test.md +100 -0
  41. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/scenario-test.md +63 -0
  42. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/security-test.md +55 -0
  43. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/ui-test.md +49 -0
  44. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/usability-test.md +46 -0
  45. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/writing-guide.md +111 -0
  46. package/src/constants.ts +1 -1
@@ -9,6 +9,7 @@ require "optparse"
9
9
  require "yaml"
10
10
  require_relative "control_io"
11
11
  require_relative "requirement_graph"
12
+ require_relative "checker_registry"
12
13
 
13
14
  POLICY = {
14
15
  "domain-modeling" => { "checkers" => %w[requirement-graph-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[term object behavior rule boundary] },
@@ -26,6 +27,7 @@ POLICY = {
26
27
  "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] },
27
28
  "execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
28
29
  "run-acceptance-tests" => { "checkers" => %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => [] },
30
+ "code-review" => { "checkers" => %w[], "agents" => ["devflow-code-reviewer"], "budget" => 3, "allowed_graph_delta" => [] },
29
31
  "verify" => { "checkers" => %w[acceptance-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[acceptance] }
30
32
  }.freeze
31
33
 
@@ -91,17 +93,40 @@ begin
91
93
  raise ArgumentError, "stale graph revision: expected #{options[:expected_graph_revision]}, actual #{graph["revision"]}" if options[:expected_graph_revision] && graph["revision"] != options[:expected_graph_revision]
92
94
  end
93
95
  policy = POLICY.fetch(options[:step], { "checkers" => [], "agents" => [], "budget" => 0 })
96
+ # The active workflow is the source of truth for gate checkers. POLICY
97
+ # retains execution permissions and budgets, but must not maintain a second
98
+ # checker list that can drift from workflow governance.
99
+ workflow_checkers = workflow.dig("governance", "step_checkers", options[:step])
100
+ checker_ids = workflow_checkers.nil? ? Array(policy["checkers"]) : Array(workflow_checkers).map(&:to_s)
101
+ CheckerRegistry.validate!(checker_ids)
102
+ CheckerRegistry.validate_executable!(checker_ids)
94
103
  paths = (Array(step["outputs"]).map { |output| output["path"] } + [step.dig("meta_output", "path")]).compact.map { |value| interpolate(value, state) }.uniq
104
+ # Execute contracts must authorize the production files declared by the
105
+ # approved change plan. Workflow outputs only contain progress artifacts;
106
+ # omitting production_paths strands implementation workers in a contract
107
+ # that can write summaries but never code.
108
+ if options[:step] == "execute"
109
+ binding_path = File.join(root, ".planning/features", options[:feature], "test-bindings.json")
110
+ if File.file?(binding_path)
111
+ bindings = JSON.parse(File.read(binding_path))
112
+ Array(bindings["tasks"]).each do |task|
113
+ Array(task["production_paths"]).each do |production_path|
114
+ paths << interpolate(production_path.to_s, state) unless production_path.to_s.empty?
115
+ end
116
+ end
117
+ end
118
+ end
119
+ paths = paths.uniq
95
120
  contract = {
96
121
  "schema_version" => 2, "origin" => "core_execution_contract", "feature" => options[:feature], "step" => options[:step],
97
122
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
98
123
  "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
99
- "allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
124
+ "allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => checker_ids,
100
125
  # Core read/write tools required by the architecture feasibility skill are
101
126
  # part of the frozen contract. The tool layer still applies per-tool
102
127
  # mutation ownership and the step's allowed paths.
103
128
  "allowed_tools" => %w[
104
- read grep find glob lsp search write edit apply_patch bash shell exec_command task
129
+ read grep find glob lsp search write edit apply_patch bash shell exec_command task todo
105
130
  devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
106
131
  devflow_requirement_graph devflow_static_runtime_context
107
132
  devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
@@ -2,12 +2,14 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "open3"
5
6
  require_relative "gate_result"
6
7
  require_relative "gate_contract"
7
8
  require_relative "checker_registry"
8
9
  require_relative "parallel_run"
9
10
  require_relative "business_model_gate"
10
11
  require_relative "requirement_graph"
12
+ require_relative "checkpoint_meta"
11
13
 
12
14
  module GateRuntime
13
15
  KNOWN_VALIDATIONS = %w[static-runtime-context requirement-graph].freeze
@@ -69,8 +71,32 @@ module GateRuntime
69
71
  missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
70
72
  missing_outputs.concat(required_paths(contract.dig("outputs", "required_inputs"), root, workflow_state, purpose: :completion))
71
73
  return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
74
+ checkpoint = contract.dig("topology", "checkpoint")
75
+ if checkpoint.is_a?(Hash) && checkpoint["producing_step"].to_s == step_id.to_s
76
+ meta_errors = CheckpointMeta.validate(root: root, state: workflow_state, checkpoint: checkpoint)
77
+ unless meta_errors.empty?
78
+ return GateResult.block(**base, code: "CHECKPOINT_META_INVALID", message: "checkpoint meta output is missing or invalid", details: { "checkpoint" => checkpoint["id"], "errors" => meta_errors })
79
+ end
80
+ end
72
81
  invalid_outputs = validate_declared_inputs(contract.dig("outputs", "required_inputs"), root, workflow_state, purpose: :completion)
73
82
  return GateResult.block(**base, code: "REQUIRED_OUTPUT_INVALID", message: "required step input/output fact is invalid", details: { "invalid" => invalid_outputs }) unless invalid_outputs.empty?
83
+ if step_id == "code-review"
84
+ contract_view = File.join(root, ".planning/execution-contract.md")
85
+ contract_id = if File.file?(contract_view) && !File.symlink?(contract_view)
86
+ File.read(contract_view).match(/^- Contract ID: `(EC-[0-9a-f]{16})`$/)&.[](1)
87
+ end
88
+ unless contract_id
89
+ return GateResult.block(**base, code: "REVIEW_GATE_NOT_PASSED", message: "current Core review contract identity is unavailable")
90
+ end
91
+ script = File.join(__dir__, "review_run.py")
92
+ stdout, stderr, process = Open3.capture3("python3", script, File.realpath(root), "--gate", workflow_state.fetch("current_feature").to_s, contract_id)
93
+ review = JSON.parse(stdout) rescue nil
94
+ unless process.success? && review.is_a?(Hash) && review["status"] == "pass"
95
+ return GateResult.block(**base, code: "REVIEW_GATE_NOT_PASSED", message: "independent Core review has not passed", details: {
96
+ "review" => review, "error" => stderr.to_s.empty? ? nil : stderr.to_s[0, 1000]
97
+ })
98
+ end
99
+ end
74
100
  end
75
101
  end
76
102
  end
@@ -84,6 +110,12 @@ module GateRuntime
84
110
  unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
85
111
  return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
86
112
  end
113
+ if root
114
+ meta_errors = CheckpointMeta.validate(root: root, state: workflow_state, checkpoint: checkpoint)
115
+ unless meta_errors.empty?
116
+ return GateResult.block(**base, code: "CHECKPOINT_META_INVALID", message: "checkpoint meta output is missing or invalid", details: { "checkpoint" => checkpoint_id, "errors" => meta_errors })
117
+ end
118
+ end
87
119
  end
88
120
  GateResult.allow(**base)
89
121
  rescue KeyError, ArgumentError => e
@@ -60,47 +60,50 @@ unless PhaseOwnership.valid_slug?(phase)
60
60
  end
61
61
  workflow_config = ".planning/workflows/#{options[:flow]}.yaml"
62
62
  workflow_path = File.expand_path(workflow_config, root)
63
- fallback_pending_steps = [
64
- "requirement-triage",
65
- "requirement-wayfinder",
66
- "domain-modeling",
67
- "requirement-feasibility-review",
68
- "requirement-baseline",
69
- "api-contract",
70
- "backend-tech-spec",
71
- "api-call-guide",
72
- "ui-spec",
73
- "feature-breakdown",
74
- "frontend-tech-spec",
75
- "api-supplement",
76
- "integration-verify",
77
- "change-plan",
78
- "phase-structure",
79
- "execute",
80
- "code-index-refresh",
81
- "code-review",
82
- "generate-mainflow-cases",
83
- "verify",
84
- "audit",
85
- "update-navigation",
86
- "archive"
87
- ]
88
- pending_steps = if File.exist?(workflow_path)
89
- workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
90
- # The template is authored in readable sections, so physical
91
- # YAML position is not a scheduling contract. A step with an
92
- # explicit order must initialize ahead of later ordered
93
- # steps even when it was inserted lower in the document.
94
- Array(workflow["steps"])
95
- .each_with_index
96
- .sort_by do |(step, index)|
97
- order = step["order"]
98
- [order.is_a?(Integer) ? 0 : 1, order.is_a?(Integer) ? order : index, index]
99
- end
100
- .map { |(step, _index)| step.fetch("id") }
101
- else
102
- fallback_pending_steps
103
- end
63
+ unless options[:flow].is_a?(String) && options[:flow].match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
64
+ puts JSON.pretty_generate({
65
+ "ok" => false,
66
+ "reason" => "invalid_flow",
67
+ "message" => "flow must be a safe workflow id"
68
+ })
69
+ exit 0
70
+ end
71
+ unless File.file?(workflow_path)
72
+ puts JSON.pretty_generate({
73
+ "ok" => false,
74
+ "reason" => "workflow_not_found",
75
+ "message" => "Workflow configuration does not exist: #{workflow_config}. Run init_workflow.rb first or choose a registered flow."
76
+ })
77
+ exit 0
78
+ end
79
+
80
+ begin
81
+ workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
82
+ steps = workflow.is_a?(Hash) ? workflow["steps"] : nil
83
+ unless steps.is_a?(Array) && steps.any? && steps.all? { |step| step.is_a?(Hash) && step["id"].is_a?(String) && !step["id"].empty? }
84
+ raise ArgumentError, "workflow steps must be a non-empty list with string ids"
85
+ end
86
+ step_ids = steps.map { |step| step["id"] }
87
+ raise ArgumentError, "workflow step ids must be unique" unless step_ids.uniq.length == step_ids.length
88
+ rescue Psych::Exception, ArgumentError => error
89
+ puts JSON.pretty_generate({
90
+ "ok" => false,
91
+ "reason" => "invalid_workflow",
92
+ "message" => "Invalid workflow configuration #{workflow_config}: #{error.message}"
93
+ })
94
+ exit 0
95
+ end
96
+
97
+ # The template is authored in readable sections, so physical YAML position is
98
+ # not a scheduling contract. A step with an explicit order must initialize
99
+ # ahead of later ordered steps even when it was inserted lower in the document.
100
+ pending_steps = steps
101
+ .each_with_index
102
+ .sort_by do |(step, index)|
103
+ order = step["order"]
104
+ [order.is_a?(Integer) ? 0 : 1, order.is_a?(Integer) ? order : index, index]
105
+ end
106
+ .map { |(step, _index)| step.fetch("id") }
104
107
  current_step = options[:step] || pending_steps.first || "requirement-triage"
105
108
 
106
109
  state = {
@@ -81,7 +81,7 @@ changes = targets.map do |template_name, target_path|
81
81
  same_as_template = exists && File.read(target) == File.read(source)
82
82
  state_mismatch = blocked_target_paths.include?(target_path)
83
83
  action = if exists && !options[:force]
84
- same_as_template ? "skip_existing" : "skip_existing_changed"
84
+ same_as_template ? "skip_existing" : "blocked_template_drift"
85
85
  elsif state_mismatch && !options[:allow_state_mismatch]
86
86
  "blocked_state_mismatch"
87
87
  elsif blocked_target_paths.any?
@@ -104,14 +104,15 @@ changes = targets.map do |template_name, target_path|
104
104
  }
105
105
  end
106
106
 
107
- blocked = changes.any? { |change| change["action"] == "blocked_state_mismatch" }
107
+ blocked = changes.any? { |change| %w[blocked_state_mismatch blocked_template_drift].include?(change["action"]) }
108
+ template_drift = changes.any? { |change| change["action"] == "blocked_template_drift" }
108
109
  result = {
109
110
  "ok" => !blocked,
110
111
  "written" => options[:write] && !blocked,
111
112
  "force" => options[:force],
112
113
  "allow_state_mismatch" => options[:allow_state_mismatch],
113
- "upgrade_hint" => changes.any? { |change| change["action"] == "skip_existing_changed" } ? "Existing generated workflow differs from the bundled template. Use --force only when intentionally upgrading generated workflow files." : nil,
114
- "blocked_reason" => blocked ? "active_state_step_mismatch" : nil,
114
+ "upgrade_hint" => template_drift ? "Existing generated workflow differs from the bundled template. Review the diff, then rerun with --force to intentionally upgrade generated workflow files." : nil,
115
+ "blocked_reason" => template_drift ? "template_drift" : (blocked ? "active_state_step_mismatch" : nil),
115
116
  "changes" => changes
116
117
  }.reject { |_, value| value.nil? }
117
118
  ensure
@@ -5,6 +5,7 @@ require "json"
5
5
  require "optparse"
6
6
  require "yaml"
7
7
  require_relative "validate_state"
8
+ require_relative "checkpoint_meta"
8
9
 
9
10
  options = { json: false, state: nil }
10
11
  OptionParser.new do |opts|
@@ -34,11 +35,19 @@ def load_yaml_file(path)
34
35
  end
35
36
 
36
37
  def interpolate_path(path, state)
37
- path
38
+ path.to_s
38
39
  .gsub("{feature}", state.fetch("current_feature", "").to_s)
39
40
  .gsub("{phase}", state.fetch("current_phase", "").to_s)
40
41
  end
41
42
 
43
+ def input_payload(input, state)
44
+ if input.is_a?(Hash)
45
+ input.merge("path" => interpolate_path(input.fetch("path"), state))
46
+ else
47
+ interpolate_path(input, state)
48
+ end
49
+ end
50
+
42
51
  def load_artifact_registry(root, workflow)
43
52
  registry_config = workflow["artifact_registry"]
44
53
  registry_path = registry_config && registry_config["source"]
@@ -121,7 +130,7 @@ def step_payload(step, state, root, artifact_by_path)
121
130
  "label" => step["label"],
122
131
  "skill" => step["skill"],
123
132
  "action" => step["action"],
124
- "inputs" => Array(step["inputs"]).map { |path| interpolate_path(path, state) },
133
+ "inputs" => Array(step["inputs"]).map { |input| input_payload(input, state) },
125
134
  "outputs" => Array(step["outputs"]).map { |output| output_payload(output, state, root, artifact_by_path) }
126
135
  }
127
136
  end
@@ -575,7 +584,7 @@ result =
575
584
  label: next_step["label"],
576
585
  skill: next_step["skill"],
577
586
  action: next_step["action"],
578
- inputs: Array(next_step["inputs"]).map { |path| interpolate_path(path, state) },
587
+ inputs: Array(next_step["inputs"]).map { |input| input_payload(input, state) },
579
588
  outputs: Array(next_step["outputs"]).map do |output|
580
589
  resolved = interpolate_path(output.fetch("path"), state)
581
590
  artifact = artifact_by_path[resolved]
@@ -624,11 +633,13 @@ result =
624
633
  missing_outputs = current_outputs
625
634
  .select { |output| output["required"] && !output["exists"] }
626
635
  .map { |output| output["resolved_path"] }
627
-
636
+ checkpoint_meta_errors = CheckpointMeta.validate(root: root, state: state, checkpoint: checkpoint)
637
+ missing_outputs.concat(checkpoint_meta_errors.map { |entry| entry["path"] }.compact)
628
638
  base.merge(
629
639
  action: missing_outputs.empty? ? "await_user_confirmation" : "blocked",
630
640
  reason: missing_outputs.empty? ? nil : "missing_checkpoint_outputs",
631
641
  missing_outputs: missing_outputs,
642
+ checkpoint_meta_errors: checkpoint_meta_errors,
632
643
  resume_behavior: checkpoint && checkpoint["resume_behavior"],
633
644
  next_intent: ".planning/.next-intent"
634
645
  )
@@ -11,6 +11,8 @@ require "yaml"
11
11
  require_relative "control_io"
12
12
  require_relative "requirement_graph"
13
13
  require_relative "checker_registry"
14
+ require_relative "checker_adapter"
15
+ require_relative "checkpoint_meta"
14
16
 
15
17
  options = { feature: nil, step: nil, contract: nil, write: false, json: false, branch_id: nil, attempt: nil, branch_revision: nil, lock_held: false }
16
18
  OptionParser.new do |o|
@@ -199,6 +201,11 @@ begin
199
201
 
200
202
  output_specs = Array(step["outputs"]).select { |item| item["required"] }
201
203
  output_specs << step["meta_output"] if step["meta_output"].is_a?(Hash)
204
+ checkpoint = workflow.dig("checkpoints", step["checkpoint"])
205
+ checkpoint_meta_errors = CheckpointMeta.validate(root: root, state: state, checkpoint: checkpoint)
206
+ if checkpoint.is_a?(Hash) && checkpoint["required_meta_output"]
207
+ output_specs << { "path" => checkpoint["required_meta_output"], "required" => true }
208
+ end
202
209
  files = output_specs.map do |spec|
203
210
  path = spec["path"]
204
211
  next unless path
@@ -208,21 +215,20 @@ begin
208
215
  { "path" => relative, "required" => true, "exists" => exists, "bytes" => exists ? File.size(absolute) : 0,
209
216
  "sha256" => exists ? Digest::SHA256.file(absolute).hexdigest : nil }
210
217
  end.compact
211
- input_files = Array(step["inputs"]).map { |input| file_evidence(root, interpolate(input.to_s, state)) }
218
+ input_files = Array(step["inputs"]).map do |input|
219
+ raw_path = input.is_a?(Hash) ? input.fetch("path") : input
220
+ file_evidence(root, interpolate(raw_path.to_s, state))
221
+ end
212
222
 
213
223
  graph, = RequirementGraph.load_graph(root, options[:feature])
214
224
  checkers = RequirementGraph.checker_results(graph)
215
225
  selected = configured_checkers(workflow, options[:step])
216
226
  CheckerRegistry.validate!(selected)
227
+ CheckerRegistry.validate_executable!(selected)
217
228
  gate = workflow.dig("governance", "requirement_graph_gate").to_s
218
229
  enforced = gate == "enforced"
219
230
  checker_results = selected.map do |id|
220
- supplemental = supplemental_checker(root, options[:feature], id, options[:step])
221
- next supplemental if supplemental
222
-
223
- issues = checkers.fetch(id, ["unknown fixed checker: #{id}"])
224
- { "id" => id, "origin" => "core_fixed_checker", "exit_code" => issues.empty? ? 0 : 1, "passed" => issues.empty?, "issues" => issues,
225
- "counters" => { "issue_count" => issues.length } }
231
+ CheckerAdapter.execute(root: root, feature: options[:feature], step: options[:step], id: id, graph_results: checkers)
226
232
  end
227
233
  graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
228
234
  checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
@@ -248,14 +254,14 @@ begin
248
254
  "counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
249
255
  "stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
250
256
  "view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
251
- "complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
257
+ "complete" => files.all? { |item| item["exists"] } && checkpoint_meta_errors.empty? && checker_passed && evidence_complete,
252
258
  "evidence_complete" => evidence_complete,
253
259
  "gate_mode" => gate.empty? ? "observe" : gate
254
260
  }.compact
255
261
  suffix = binding ? ".attempt-#{binding["attempt"]}.branch-#{Digest::SHA256.hexdigest(binding["branch_id"].to_s)[0, 12]}" : ""
256
262
  destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}#{suffix}.machine.json")
257
263
  DevflowControlIO.atomic_write(destination, JSON.pretty_generate(pack) + "\n")
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 }
264
+ result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results, "checkpoint_meta_errors" => checkpoint_meta_errors, "parallel_binding" => binding }
259
265
  rescue StandardError => error
260
266
  result = { "ok" => false, "reason" => "machine_evidence_failed", "message" => error.message }
261
267
  ensure
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only Core review preflight. Never runs an AI review or changes project files."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+
12
+
13
+ def sha256(path: Path) -> str:
14
+ return hashlib.sha256(path.read_bytes()).hexdigest()
15
+
16
+
17
+ def within(root: Path, relative: str) -> Path:
18
+ if not isinstance(relative, str) or not relative or Path(relative).is_absolute() or "\x00" in relative:
19
+ raise ValueError("invalid relative path")
20
+ path = (root / relative).resolve()
21
+ if not path.is_relative_to(root):
22
+ raise ValueError("path escapes project")
23
+ return path
24
+
25
+
26
+ def load_workflow(path: Path) -> dict:
27
+ command = ["ruby", "-ryaml", "-rjson", "-e",
28
+ "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0)), permitted_classes: [], aliases: false))",
29
+ str(path)]
30
+ result = subprocess.run(command, text=True, capture_output=True, timeout=15, check=False)
31
+ if result.returncode:
32
+ raise ValueError(f"workflow cannot be read: {result.stderr.strip()}")
33
+ return json.loads(result.stdout)
34
+
35
+
36
+ def load_state(path: Path) -> dict:
37
+ command = ["ruby", "-ryaml", "-rjson", "-e",
38
+ "front = File.read(ARGV.fetch(0)).match(/\\A---\\n(.*?)\\n---/m); "
39
+ "abort 'STATE front matter missing' unless front; "
40
+ "puts JSON.generate(YAML.safe_load(front[1], permitted_classes: [], aliases: false))",
41
+ str(path)]
42
+ result = subprocess.run(command, text=True, capture_output=True, timeout=15, check=False)
43
+ if result.returncode:
44
+ raise ValueError(f"STATE cannot be read: {result.stderr.strip()}")
45
+ return json.loads(result.stdout)
46
+
47
+
48
+ def preflight(root: Path, feature: str, step: str, contract_path: str,
49
+ obligations: list[dict], review_agent: str | None = None,
50
+ review_contract_path: str | None = None, stage: str = "completion") -> dict:
51
+ root = root.resolve()
52
+ evidence, issues = [], []
53
+ if stage not in {"entry", "completion"}:
54
+ return {"status": "human_confirmation", "scope": "snapshot_preflight_only_not_gate_approval",
55
+ "evidence": [], "issues": [{"code": "INVALID_STAGE"}]}
56
+ try:
57
+ frozen_path = within(root, contract_path)
58
+ frozen = json.loads(frozen_path.read_text(encoding="utf-8"))
59
+ command = ["ruby", str(Path(__file__).with_name("execution_contract.rb")),
60
+ "inspect", "--feature", feature, "--step", step, "--json"]
61
+ run = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=15, check=False)
62
+ current_result = json.loads(run.stdout)
63
+ if run.returncode or not current_result.get("ok"):
64
+ return {"status": "human_confirmation", "evidence": evidence,
65
+ "issues": [{"code": "CURRENT_TRUTH_UNAVAILABLE", "detail": current_result.get("message", run.stderr)}]}
66
+ current = current_result["contract"]
67
+ evidence.extend([
68
+ {"kind": "state_graph", "state_revision": current["state_revision"],
69
+ "graph_revision": current["graph_revision"]},
70
+ {"kind": "contract", "path": contract_path, "sha256": sha256(frozen_path),
71
+ "contract_id": frozen.get("contract_id")},
72
+ ])
73
+ if frozen != current:
74
+ return {"status": "human_confirmation", "evidence": evidence,
75
+ "issues": [{"code": "STALE_EXECUTION_CONTRACT",
76
+ "detail": "frozen contract differs from current Core snapshot; recreate and retry"}]}
77
+ if frozen.get("feature") != feature or frozen.get("step") != step:
78
+ issues.append({"code": "CONTRACT_TARGET_MISMATCH", "detail": "contract feature/step mismatch"})
79
+ workflow_path = within(root, current["workflow_path"])
80
+ if sha256(workflow_path) != current["workflow_fingerprint"]:
81
+ return {"status": "human_confirmation", "evidence": evidence,
82
+ "issues": [{"code": "STALE_WORKFLOW_SNAPSHOT"}]}
83
+ workflow = load_workflow(workflow_path)
84
+ state = load_state(within(root, ".planning/STATE.md"))
85
+ if state.get("revision") != current["state_revision"] or state.get("current_feature") != feature or state.get("current_step") != step:
86
+ return {"status": "human_confirmation", "evidence": evidence,
87
+ "issues": [{"code": "STALE_STATE_SNAPSHOT"}]}
88
+ step_definition = next((item for item in workflow.get("steps", [])
89
+ if isinstance(item, dict) and item.get("id") == step), None)
90
+ if step_definition is None:
91
+ raise ValueError("current workflow step unavailable")
92
+ render = lambda value: value.replace("{feature}", feature).replace("{phase}", str(state["current_phase"]))
93
+ derived = []
94
+ for raw in step_definition.get("inputs", []):
95
+ if isinstance(raw, str):
96
+ relative, required = raw, True
97
+ elif isinstance(raw, dict) and isinstance(raw.get("path"), str):
98
+ relative = raw["path"]
99
+ required = (raw.get("required", True) and raw.get("required_at_entry", True)) if stage == "entry" else raw.get("required_at_completion", raw.get("required", True))
100
+ else:
101
+ issues.append({"code": "INVALID_WORKFLOW_INPUT", "detail": "unrecognized input obligation"})
102
+ continue
103
+ if required and relative != ".planning/execution-contract.md":
104
+ derived.append({"path": render(relative), "producer": "existing"})
105
+ for item in step_definition.get("outputs", []):
106
+ if isinstance(item, dict) and item.get("required") is True and isinstance(item.get("path"), str):
107
+ derived.append({"path": render(item["path"]), "producer": "step"})
108
+ meta = step_definition.get("meta_output")
109
+ if isinstance(meta, dict) and isinstance(meta.get("path"), str):
110
+ derived.append({"path": render(meta["path"]), "producer": "step"})
111
+ evidence.append({"kind": "workflow", "path": current["workflow_path"],
112
+ "sha256": current["workflow_fingerprint"],
113
+ "required_obligation_count": len(derived),
114
+ "checker_ids": current.get("checker_ids", [])})
115
+ if step == "code-review":
116
+ graph_relative = f".planning/requirements/{feature}/governance.json"
117
+ graph_path = within(root, graph_relative)
118
+ if not graph_path.is_file():
119
+ issues.append({"code": "CONFIRMED_EXPECTATIONS_UNAVAILABLE", "path": graph_relative})
120
+ else:
121
+ graph = json.loads(graph_path.read_text(encoding="utf-8"))
122
+ stale = {entry.get("node_id") for entry in graph.get("stale", []) if isinstance(entry, dict)}
123
+ relevant = [node for node in graph.get("nodes", [])
124
+ if isinstance(node, dict) and node.get("kind") in {"requirement", "rule", "boundary", "acceptance"}]
125
+ unconfirmed = [node.get("id", "unknown") for node in relevant
126
+ if node.get("status") != "confirmed" or node.get("id") in stale
127
+ or not isinstance(node.get("confirmation"), dict)
128
+ or node["confirmation"].get("origin") != "core"
129
+ or not node["confirmation"].get("evidence")]
130
+ if graph.get("feature") != feature or graph.get("revision") != current["graph_revision"] or not relevant:
131
+ issues.append({"code": "CONFIRMED_EXPECTATIONS_UNAVAILABLE", "path": graph_relative})
132
+ elif unconfirmed:
133
+ issues.append({"code": "UNCONFIRMED_EXPECTATIONS", "path": graph_relative,
134
+ "node_ids": unconfirmed,
135
+ "detail": "Core review scope is not available; confirm or scope these nodes before task dispatch"})
136
+ else:
137
+ evidence.append({"kind": "confirmed_expectations", "path": graph_relative,
138
+ "sha256": sha256(graph_path), "graph_revision": graph["revision"],
139
+ "node_ids": [node["id"] for node in relevant]})
140
+ if not derived and not current.get("checker_ids"):
141
+ issues.append({"code": "NO_REVIEW_OBLIGATIONS", "detail": "Core workflow defines no review obligations"})
142
+ supplied = {item.get("path"): item for item in obligations if isinstance(item, dict)}
143
+ for item in derived:
144
+ supplement = supplied.get(item["path"], {})
145
+ if supplement.get("producer") not in (None, item["producer"]):
146
+ issues.append({"code": "OBLIGATION_PRODUCER_MISMATCH", "path": item["path"]})
147
+ item.update({key: value for key, value in supplement.items() if key == "sha256"})
148
+ obligations = derived + [item for item in obligations if not isinstance(item, dict) or item.get("path") not in {d["path"] for d in derived}]
149
+ allowed = set(current.get("allowed_paths", []))
150
+ for index, obligation in enumerate(obligations):
151
+ if not isinstance(obligation, dict) or not isinstance(obligation.get("path"), str):
152
+ issues.append({"code": "INVALID_OBLIGATION", "index": index})
153
+ continue
154
+ relative = obligation["path"]
155
+ try:
156
+ path = within(root, relative)
157
+ except ValueError as error:
158
+ issues.append({"code": "INVALID_OBLIGATION_PATH", "index": index, "detail": str(error)})
159
+ continue
160
+ producer = obligation.get("producer")
161
+ if producer == "existing":
162
+ if not path.is_file():
163
+ issues.append({"code": "MISSING_INPUT_FACT", "index": index, "path": relative})
164
+ elif obligation.get("sha256") is not None and obligation.get("sha256") != sha256(path):
165
+ issues.append({"code": "STALE_INPUT_FACT", "index": index, "path": relative})
166
+ else:
167
+ evidence.append({"kind": "input", "path": relative, "sha256": sha256(path)})
168
+ elif producer == "step":
169
+ if relative not in allowed:
170
+ issues.append({"code": "UNREACHABLE_OBLIGATION", "index": index, "path": relative,
171
+ "detail": "step contract does not allow producing this path"})
172
+ else:
173
+ evidence.append({"kind": "producer", "path": relative, "step": step,
174
+ "contract_id": current["contract_id"]})
175
+ if path.is_file():
176
+ evidence.append({"kind": "output_snapshot", "path": relative, "sha256": sha256(path)})
177
+ elif stage == "completion":
178
+ issues.append({"code": "MISSING_REVIEW_OUTPUT", "index": index, "path": relative})
179
+ else:
180
+ issues.append({"code": "UNKNOWN_PRODUCER", "index": index, "path": relative})
181
+ if step == "code-review" and review_agent not in (None, "devflow-code-reviewer"):
182
+ issues.append({"code": "REVIEW_AGENT_MISMATCH", "agent": review_agent,
183
+ "detail": "code-review requires the independent devflow-code-reviewer"})
184
+ if step == "code-review":
185
+ review_agent = "devflow-code-reviewer"
186
+ if review_agent:
187
+ reviewer_contract = current
188
+ if review_contract_path:
189
+ review_path = within(root, review_contract_path)
190
+ reviewer_contract = json.loads(review_path.read_text(encoding="utf-8"))
191
+ evidence.append({"kind": "review_contract", "path": review_contract_path,
192
+ "sha256": sha256(review_path)})
193
+ if reviewer_contract.get("feature") != feature or reviewer_contract.get("graph_revision") != current.get("graph_revision"):
194
+ issues.append({"code": "STALE_REVIEW_CONTRACT"})
195
+ # A separate step cannot be inspected while STATE points at the execution
196
+ # step. A supplied JSON file alone is not Core authority.
197
+ issues.append({"code": "REVIEW_CONTRACT_UNVERIFIED",
198
+ "detail": "separate review contract needs a Core-verified step transition"})
199
+ if review_agent not in reviewer_contract.get("allowed_agents", []) or reviewer_contract.get("tool_budget", 0) < 1:
200
+ issues.append({"code": "REVIEW_AGENT_UNREACHABLE", "agent": review_agent,
201
+ "detail": "review agent is not allowed or task budget is insufficient"})
202
+ except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
203
+ return {"status": "human_confirmation", "evidence": evidence,
204
+ "issues": [{"code": "PREFLIGHT_INPUT_UNAVAILABLE", "detail": str(error)}]}
205
+ conflict_codes = {"CONTRACT_TARGET_MISMATCH", "UNREACHABLE_OBLIGATION", "REVIEW_AGENT_MISMATCH",
206
+ "REVIEW_AGENT_UNREACHABLE", "STALE_REVIEW_CONTRACT"}
207
+ status = ("core_contract_conflict" if any(issue["code"] in conflict_codes for issue in issues)
208
+ else "human_confirmation" if issues else "ready")
209
+ return {"status": status, "scope": "snapshot_preflight_only_not_gate_approval",
210
+ "evidence": evidence, "issues": issues}
211
+
212
+
213
+ def main() -> int:
214
+ parser = argparse.ArgumentParser(description=__doc__)
215
+ parser.add_argument("--root", type=Path, default=Path.cwd())
216
+ parser.add_argument("--feature", required=True)
217
+ parser.add_argument("--step", required=True)
218
+ parser.add_argument("--contract", required=True, help="project-relative frozen contract JSON")
219
+ parser.add_argument("--obligations", required=True, help="JSON array: {path, producer: existing|step, sha256?}")
220
+ parser.add_argument("--review-agent")
221
+ parser.add_argument("--review-contract", help="separate project-relative reviewer contract JSON")
222
+ parser.add_argument("--stage", choices=["entry", "completion"], default="completion")
223
+ args = parser.parse_args()
224
+ try:
225
+ obligations = json.loads(args.obligations)
226
+ if not isinstance(obligations, list):
227
+ raise ValueError("obligations must be an array")
228
+ result = preflight(args.root.resolve(), args.feature, args.step, args.contract,
229
+ obligations, args.review_agent, args.review_contract, args.stage)
230
+ except ValueError as error:
231
+ result = {"status": "human_confirmation", "evidence": [],
232
+ "issues": [{"code": "INVALID_INPUT", "detail": str(error)}]}
233
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
234
+ return 0 if result["status"] == "ready" else 2
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())