@guanlin4924/devflow-pi 0.1.86 → 0.1.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +199 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +15 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +49 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_adapter.rb +83 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +19 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checkpoint_meta.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +10 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +32 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +44 -41
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +5 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +15 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_preflight.py +238 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +234 -32
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +63 -11
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +5 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +40 -31
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +11 -5
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +6 -6
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +16 -1
- package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +3 -1
- package/plugins/devflow-quality/agents/code-reviewer.md +7 -1
- package/plugins/devflow-quality/skills/code-review/SKILL.md +10 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +37 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/SKILL.md +327 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/assets/output-template.md +213 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/bug-rules.md +9 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/coverage-checklist.md +63 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/exception-checklist.md +37 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/api-test.md +58 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/boundary-test.md +151 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/common-test.md +49 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/compatibility-test.md +105 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/data-test.md +67 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/exception-test.md +109 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/function-test.md +65 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/integration-test.md +36 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/performance-test.md +100 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/scenario-test.md +63 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/security-test.md +55 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/ui-test.md +49 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/usability-test.md +46 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/writing-guide.md +111 -0
- 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,12 +93,19 @@ 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
|
|
95
104
|
contract = {
|
|
96
105
|
"schema_version" => 2, "origin" => "core_execution_contract", "feature" => options[:feature], "step" => options[:step],
|
|
97
106
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
|
|
98
107
|
"workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
|
|
99
|
-
"allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" =>
|
|
108
|
+
"allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => checker_ids,
|
|
100
109
|
# Core read/write tools required by the architecture feasibility skill are
|
|
101
110
|
# part of the frozen contract. The tool layer still applies per-tool
|
|
102
111
|
# mutation ownership and the step's allowed paths.
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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" : "
|
|
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"]
|
|
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" =>
|
|
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 { |
|
|
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 { |
|
|
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
|
|
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
|
-
|
|
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())
|