@guanlin4924/devflow-pi 0.1.96 → 0.1.98
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 +11 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +697 -91
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +1015 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +12 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +72 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/startup-briefing.ts +109 -0
|
@@ -65,6 +65,16 @@ module RequirementGraph
|
|
|
65
65
|
nil
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
def active_flow(root)
|
|
69
|
+
state_path = File.join(root, ".devflow/planning/STATE.md")
|
|
70
|
+
return nil unless File.file?(state_path)
|
|
71
|
+
front = File.read(state_path).match(/\A---\n(.*?)\n---/m)&.[](1)
|
|
72
|
+
return nil unless front
|
|
73
|
+
YAML.safe_load(front, permitted_classes: [], aliases: false)["current_flow"]
|
|
74
|
+
rescue Psych::Exception
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
68
78
|
def assert_active_feature!(root, feature)
|
|
69
79
|
current = active_feature(root)
|
|
70
80
|
return if current.nil? || current == feature
|
|
@@ -188,7 +198,7 @@ module RequirementGraph
|
|
|
188
198
|
graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
|
|
189
199
|
end
|
|
190
200
|
|
|
191
|
-
def proposal(graph, delta, allowed_kinds = nil)
|
|
201
|
+
def proposal(graph, delta, allowed_kinds = nil, regression: false)
|
|
192
202
|
raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
|
|
193
203
|
case delta["op"]
|
|
194
204
|
when "add"
|
|
@@ -198,7 +208,7 @@ module RequirementGraph
|
|
|
198
208
|
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
199
209
|
data = normalize_data(delta["data"])
|
|
200
210
|
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
201
|
-
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
211
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node, regression: regression)
|
|
202
212
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
203
213
|
set_node_fingerprint!(node)
|
|
204
214
|
graph["nodes"] << node
|
|
@@ -220,7 +230,7 @@ module RequirementGraph
|
|
|
220
230
|
end
|
|
221
231
|
data = normalize_data(delta["data"])
|
|
222
232
|
node["data"] = node.fetch("data", {}).merge(data)
|
|
223
|
-
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
233
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node, regression: regression)
|
|
224
234
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
225
235
|
set_node_fingerprint!(node)
|
|
226
236
|
clear_stale_for!(graph, id)
|
|
@@ -301,25 +311,50 @@ module RequirementGraph
|
|
|
301
311
|
# causally proven without a deterministic replay.
|
|
302
312
|
TEST_EVIDENCE_STRATEGIES = %w[replay causal observation].freeze
|
|
303
313
|
REPLAY_SCOPES = %w[component page api integration].freeze
|
|
314
|
+
ORACLE_SOURCES = %w[requirement contract invariant characterization].freeze
|
|
315
|
+
|
|
316
|
+
def oracle_source_errors(node, data, regression: false)
|
|
317
|
+
errors = []
|
|
318
|
+
source = data["oracle_source"]
|
|
319
|
+
if source.nil? && !regression
|
|
320
|
+
source = "requirement"
|
|
321
|
+
elsif !ORACLE_SOURCES.include?(source)
|
|
322
|
+
errors << "#{node["id"]}: oracle_source must be requirement, contract, invariant, or characterization"
|
|
323
|
+
return errors
|
|
324
|
+
end
|
|
325
|
+
if regression && !data.key?("oracle_source")
|
|
326
|
+
errors << "#{node["id"]}: regression TC requires explicit oracle_source"
|
|
327
|
+
end
|
|
328
|
+
if source == "requirement"
|
|
329
|
+
errors << "#{node["id"]}: requirement oracle_source requires non-empty acceptance_refs" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
|
|
330
|
+
elsif !data["oracle_evidence"].is_a?(String) || data["oracle_evidence"].strip.empty?
|
|
331
|
+
errors << "#{node["id"]}: #{source} oracle_source requires oracle_evidence"
|
|
332
|
+
end
|
|
333
|
+
errors
|
|
334
|
+
end
|
|
304
335
|
|
|
305
336
|
# TC nodes are derived verification intent. They deliberately store only
|
|
306
337
|
# externally observable setup, operation and oracle; implementation paths,
|
|
307
338
|
# selectors and agent summaries belong to later execution bindings.
|
|
308
|
-
def test_case_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
339
|
+
def test_case_errors(graph, node, require_edges: false, require_confirmed_inputs: false, regression: false)
|
|
309
340
|
return [] unless node["kind"] == "test_case"
|
|
310
341
|
|
|
311
342
|
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
312
343
|
errors = []
|
|
313
|
-
%w[
|
|
344
|
+
%w[test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
|
|
314
345
|
errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
|
|
315
346
|
end
|
|
316
|
-
|
|
317
|
-
errors << "#{node["id"]}:
|
|
347
|
+
allow_empty_refs = regression && data["oracle_source"] && data["oracle_source"] != "requirement"
|
|
348
|
+
errors << "#{node["id"]}: acceptance_refs must be present" unless data.key?("acceptance_refs")
|
|
349
|
+
errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && (!data["acceptance_refs"].empty? || allow_empty_refs)
|
|
350
|
+
errors << "#{node["id"]}: requirement_refs must be present" unless data.key?("requirement_refs")
|
|
351
|
+
errors << "#{node["id"]}: requirement_refs must be a non-empty array" unless data["requirement_refs"].is_a?(Array) && (!data["requirement_refs"].empty? || allow_empty_refs)
|
|
318
352
|
errors << "#{node["id"]}: preconditions must be a non-empty array" unless data["preconditions"].is_a?(Array) && !data["preconditions"].empty?
|
|
319
353
|
errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
|
|
320
354
|
errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
|
|
321
355
|
errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
|
|
322
356
|
errors << "#{node["id"]}: invalid evidence_strategy" unless TEST_EVIDENCE_STRATEGIES.include?(data["evidence_strategy"])
|
|
357
|
+
errors.concat(oracle_source_errors(node, data, regression: regression))
|
|
323
358
|
case data["evidence_strategy"]
|
|
324
359
|
when "replay"
|
|
325
360
|
errors << "#{node["id"]}: replay tests require replay_scope" unless REPLAY_SCOPES.include?(data["replay_scope"])
|
|
@@ -447,7 +482,7 @@ module RequirementGraph
|
|
|
447
482
|
# CP-3 is the single freeze point. Keep it here instead of in a Skill so the
|
|
448
483
|
# graph revision, TC statuses, views and audit entry commit atomically under
|
|
449
484
|
# the caller's control-plane lock.
|
|
450
|
-
def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true)
|
|
485
|
+
def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true, regression: false)
|
|
451
486
|
cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
452
487
|
return { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" } if cases.empty?
|
|
453
488
|
return { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" } if cases.any? { |node| node["status"] == "confirmed" }
|
|
@@ -460,10 +495,10 @@ module RequirementGraph
|
|
|
460
495
|
end
|
|
461
496
|
return prerequisite_confirmation unless prerequisite_confirmation["ok"]
|
|
462
497
|
|
|
463
|
-
checkers = checker_results(graph)
|
|
498
|
+
checkers = checker_results(graph, regression: regression)
|
|
464
499
|
errors = checkers.fetch("test-coverage-check", []).dup
|
|
465
500
|
errors.concat(checkers.fetch("test-obligation-check", []))
|
|
466
|
-
cases.each { |node| errors.concat(test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)) }
|
|
501
|
+
cases.each { |node| errors.concat(test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true, regression: regression)) }
|
|
467
502
|
return { "ok" => false, "reason" => "test_suite_incomplete", "message" => errors.uniq.join("; "), "errors" => errors.uniq } if errors.any?
|
|
468
503
|
|
|
469
504
|
cases.each do |node|
|
|
@@ -528,14 +563,14 @@ module RequirementGraph
|
|
|
528
563
|
comparable = node.dig("data", "execution") == candidate_data["execution"] && node.dig("data", "test_obligation_refs") == candidate_data["test_obligation_refs"]
|
|
529
564
|
raise ArgumentError, "#{item["key"]}: existing generated TC does not match matrix" unless comparable
|
|
530
565
|
else
|
|
531
|
-
applied = proposal(graph, { "op" => "add", "kind" => "test_case", "data" => candidate_data }, ["test_case"])
|
|
566
|
+
applied = proposal(graph, { "op" => "add", "kind" => "test_case", "data" => candidate_data }, ["test_case"], regression: active_flow(root).to_s == "regression-acceptance")
|
|
532
567
|
node = applied.fetch("node")
|
|
533
568
|
refs = Array(candidate_data["acceptance_refs"]) + Array(candidate_data["requirement_refs"]) + Array(candidate_data["api_refs"])
|
|
534
569
|
refs.each do |ref|
|
|
535
570
|
edge = { "from" => ref, "to" => node["id"], "relation" => "verified_by" }
|
|
536
571
|
graph["edges"] << edge unless graph["edges"].include?(edge)
|
|
537
572
|
end
|
|
538
|
-
errors = test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
573
|
+
errors = test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true, regression: active_flow(root).to_s == "regression-acceptance")
|
|
539
574
|
raise ArgumentError, errors.join("; ") if errors.any?
|
|
540
575
|
existing[item["key"]] = node
|
|
541
576
|
imported << node["id"]
|
|
@@ -827,7 +862,7 @@ module RequirementGraph
|
|
|
827
862
|
!node.nil? && node["status"] == "confirmed"
|
|
828
863
|
end
|
|
829
864
|
|
|
830
|
-
def checker_results(graph)
|
|
865
|
+
def checker_results(graph, regression: false)
|
|
831
866
|
results = {
|
|
832
867
|
"requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
|
|
833
868
|
"change-impact-check" => [], "acceptance-coverage-check" => [], "test-coverage-check" => [], "test-obligation-check" => [], "risk-derivation-check" => []
|
|
@@ -886,7 +921,7 @@ module RequirementGraph
|
|
|
886
921
|
end
|
|
887
922
|
when "test_case"
|
|
888
923
|
results["test-coverage-check"].concat(
|
|
889
|
-
test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
924
|
+
test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed", regression: regression)
|
|
890
925
|
)
|
|
891
926
|
end
|
|
892
927
|
end
|
|
@@ -1137,7 +1172,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1137
1172
|
delta = JSON.parse(options[:delta_json].to_s)
|
|
1138
1173
|
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
1139
1174
|
candidate = RequirementGraph.deep_clone(graph)
|
|
1140
|
-
|
|
1175
|
+
applied = RequirementGraph.proposal(candidate, delta, allowed_kinds, regression: RequirementGraph.active_flow(root).to_s == "regression-acceptance")
|
|
1141
1176
|
candidate["revision"] += 1
|
|
1142
1177
|
stale = Array(candidate["stale"]).any?
|
|
1143
1178
|
if options[:write]
|
|
@@ -25,6 +25,7 @@ parser = OptionParser.new do |opts|
|
|
|
25
25
|
opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
|
|
26
26
|
opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
|
|
27
27
|
opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
|
|
28
|
+
opts.on("--carry-file PATH", "JSON carry text supplied by the Runtime") { |v| options[:carry_file] = v }
|
|
28
29
|
opts.on("--lease-seconds N", Integer, "Claim lease duration") { |v| options[:lease_seconds] = v }
|
|
29
30
|
end
|
|
30
31
|
action = ARGV.shift
|
|
@@ -68,8 +69,64 @@ def lease_expiry(seconds)
|
|
|
68
69
|
[[seconds.to_i, 5].max, 3600].min.then { |duration| (Time.now.utc + duration).iso8601(3) }
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
def truncate_text(value, limit)
|
|
73
|
+
return "" unless value.is_a?(String)
|
|
74
|
+
return value if value.length <= limit
|
|
75
|
+
|
|
76
|
+
value[0, limit - 1] + "…"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_carry(path)
|
|
80
|
+
parsed = JSON.parse(File.read(path))
|
|
81
|
+
raise JSON::ParserError, "carry must be a JSON object" unless parsed.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
notes = parsed["user_notes"].is_a?(Array) ? parsed["user_notes"].select { |item| item.is_a?(String) } : []
|
|
84
|
+
{
|
|
85
|
+
"user_notes" => notes.last(10).map { |item| truncate_text(item, 500) },
|
|
86
|
+
"agent_report" => truncate_text(parsed["agent_report"], 4000)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def previous_step_payload(root, state)
|
|
91
|
+
step_id = state["completed_steps"].is_a?(Array) ? state["completed_steps"].last : nil
|
|
92
|
+
return nil unless step_id.is_a?(String) && !step_id.empty?
|
|
93
|
+
|
|
94
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state["workflow_config"], :workflow)
|
|
95
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
96
|
+
step = workflow.fetch("steps", []).find { |item| item.is_a?(Hash) && item["id"] == step_id }
|
|
97
|
+
return nil unless step
|
|
98
|
+
|
|
99
|
+
render = lambda do |value|
|
|
100
|
+
value.gsub("{feature}", state["current_feature"].to_s).gsub("{phase}", state["current_phase"].to_s)
|
|
101
|
+
end
|
|
102
|
+
paths = step.fetch("outputs", []).each_with_object([]) do |item, result|
|
|
103
|
+
result << item["path"] if item.is_a?(Hash) && item["required"] == true && item["path"].is_a?(String)
|
|
104
|
+
end
|
|
105
|
+
meta = step["meta_output"]
|
|
106
|
+
paths << meta["path"] if meta.is_a?(Hash) && meta["path"].is_a?(String)
|
|
107
|
+
outputs = paths.each_with_object([]) do |raw_path, result|
|
|
108
|
+
relative = render.call(raw_path)
|
|
109
|
+
absolute = File.expand_path(relative, root)
|
|
110
|
+
next unless absolute.start_with?("#{File.realpath(root)}#{File::SEPARATOR}") && File.exist?(absolute) && File.lstat(absolute).file?
|
|
111
|
+
|
|
112
|
+
result << { "path" => relative, "sha256" => Digest::SHA256.hexdigest(File.binread(absolute)) }
|
|
113
|
+
end
|
|
114
|
+
{ "id" => step_id, "label" => step["label"].to_s, "outputs" => outputs }
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
71
119
|
root = Dir.pwd
|
|
72
120
|
state_path, = DevflowControlIO.resolve_control_path(root, ".devflow/planning/STATE.md", :state)
|
|
121
|
+
carry = nil
|
|
122
|
+
if options[:carry_file]
|
|
123
|
+
begin
|
|
124
|
+
carry = load_carry(options[:carry_file])
|
|
125
|
+
rescue JSON::ParserError, SystemCallError
|
|
126
|
+
puts JSON.pretty_generate(fail_result("invalid_carry"))
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
end
|
|
73
130
|
result = catch(:transition_done) do
|
|
74
131
|
lock = DevflowControlIO.acquire_project_lock(root)
|
|
75
132
|
begin
|
|
@@ -149,6 +206,9 @@ result = catch(:transition_done) do
|
|
|
149
206
|
"artifact_registry" => ".devflow/planning/artifacts/index.yaml"
|
|
150
207
|
}
|
|
151
208
|
}
|
|
209
|
+
previous_step = previous_step_payload(root, state)
|
|
210
|
+
payload["previous_step"] = previous_step if previous_step
|
|
211
|
+
payload["carry"] = carry if options[:carry_file]
|
|
152
212
|
serialized = JSON.generate(payload) + "\n"
|
|
153
213
|
digest = Digest::SHA256.hexdigest(serialized)
|
|
154
214
|
if options[:write]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only startup preflight shared by every workflow execution entry path."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import review_preflight
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCOPE = "startup_preflight_only_not_gate_approval"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_path(value: str, feature: str, phase: str) -> str:
|
|
17
|
+
return value.replace("{feature}", feature).replace("{phase}", phase)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_handoff(root: Path, relative: str, evidence: list[dict], issues: list[dict]) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
handoff_path = review_preflight.within(root, relative)
|
|
23
|
+
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
|
|
24
|
+
if not isinstance(payload, dict):
|
|
25
|
+
raise ValueError("handoff must be a JSON object")
|
|
26
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
|
27
|
+
issues.append({"code": "HANDOFF_UNAVAILABLE", "detail": str(error), "path": relative})
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
previous = payload.get("previous_step")
|
|
31
|
+
if isinstance(previous, dict):
|
|
32
|
+
outputs = previous.get("outputs", [])
|
|
33
|
+
if isinstance(outputs, list):
|
|
34
|
+
for item in outputs:
|
|
35
|
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
36
|
+
continue
|
|
37
|
+
output_relative = item["path"]
|
|
38
|
+
try:
|
|
39
|
+
output_path = review_preflight.within(root, output_relative)
|
|
40
|
+
except ValueError:
|
|
41
|
+
issues.append({"code": "PREVIOUS_OUTPUT_MISSING", "path": output_relative})
|
|
42
|
+
continue
|
|
43
|
+
if not output_path.is_file():
|
|
44
|
+
issues.append({"code": "PREVIOUS_OUTPUT_MISSING", "path": output_relative})
|
|
45
|
+
elif item.get("sha256") != review_preflight.sha256(output_path):
|
|
46
|
+
issues.append({"code": "PREVIOUS_OUTPUT_CHANGED", "path": output_relative})
|
|
47
|
+
else:
|
|
48
|
+
evidence.append({"kind": "previous_output", "path": output_relative,
|
|
49
|
+
"sha256": item.get("sha256")})
|
|
50
|
+
return {
|
|
51
|
+
**({"previous_step": payload["previous_step"]} if "previous_step" in payload else {}),
|
|
52
|
+
**({"carry": payload["carry"]} if "carry" in payload else {}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def startup_preflight(root: Path, feature: str, step: str,
|
|
57
|
+
contract: str | None = None, handoff: str | None = None) -> dict:
|
|
58
|
+
root = root.resolve()
|
|
59
|
+
evidence: list[dict] = []
|
|
60
|
+
issues: list[dict] = []
|
|
61
|
+
handoff_result: dict = {}
|
|
62
|
+
contract_status = "ready"
|
|
63
|
+
facts: dict = {"feature": feature}
|
|
64
|
+
try:
|
|
65
|
+
if handoff:
|
|
66
|
+
handoff_result = read_handoff(root, handoff, evidence, issues)
|
|
67
|
+
|
|
68
|
+
if contract:
|
|
69
|
+
contract_path = review_preflight.within(root, contract)
|
|
70
|
+
frozen = json.loads(contract_path.read_text(encoding="utf-8"))
|
|
71
|
+
if not isinstance(frozen, dict):
|
|
72
|
+
raise ValueError("contract must be a JSON object")
|
|
73
|
+
contract_result = review_preflight.preflight(
|
|
74
|
+
root, feature, step, contract, [], None, None, "entry"
|
|
75
|
+
)
|
|
76
|
+
evidence.extend(contract_result.get("evidence", []))
|
|
77
|
+
issues.extend(contract_result.get("issues", []))
|
|
78
|
+
contract_status = contract_result.get("status", "human_confirmation")
|
|
79
|
+
facts["allowed_paths"] = frozen.get("allowed_paths", [])
|
|
80
|
+
facts["contract_id"] = frozen.get("contract_id", "")
|
|
81
|
+
|
|
82
|
+
state = review_preflight.load_state(
|
|
83
|
+
review_preflight.within(root, ".devflow/planning/STATE.md")
|
|
84
|
+
)
|
|
85
|
+
phase = str(state.get("current_phase", ""))
|
|
86
|
+
facts["phase"] = phase
|
|
87
|
+
workflow_relative = state.get("workflow_config")
|
|
88
|
+
if not isinstance(workflow_relative, str):
|
|
89
|
+
raise ValueError("STATE workflow_config is unavailable")
|
|
90
|
+
workflow = review_preflight.load_workflow(review_preflight.within(root, workflow_relative))
|
|
91
|
+
steps = workflow.get("steps", [])
|
|
92
|
+
if not isinstance(steps, list):
|
|
93
|
+
raise ValueError("workflow steps must be an array")
|
|
94
|
+
step_index = next(
|
|
95
|
+
(index for index, item in enumerate(steps)
|
|
96
|
+
if isinstance(item, dict) and item.get("id") == step),
|
|
97
|
+
None,
|
|
98
|
+
)
|
|
99
|
+
if step_index is None:
|
|
100
|
+
issues.append({"code": "STEP_UNAVAILABLE", "detail": f"workflow step {step} is unavailable"})
|
|
101
|
+
else:
|
|
102
|
+
definition = steps[step_index]
|
|
103
|
+
step_fact = {
|
|
104
|
+
"id": step,
|
|
105
|
+
"label": definition.get("label", step),
|
|
106
|
+
"skill": definition.get("skill", ""),
|
|
107
|
+
}
|
|
108
|
+
description = definition.get("description", definition.get("summary"))
|
|
109
|
+
if isinstance(description, str) and description:
|
|
110
|
+
step_fact["description"] = description
|
|
111
|
+
facts["step"] = step_fact
|
|
112
|
+
facts["progress"] = {"index": step_index + 1, "total": len(steps)}
|
|
113
|
+
input_facts = []
|
|
114
|
+
for raw in definition.get("inputs", []):
|
|
115
|
+
if isinstance(raw, str):
|
|
116
|
+
relative, required = raw, True
|
|
117
|
+
elif isinstance(raw, dict) and isinstance(raw.get("path"), str):
|
|
118
|
+
relative = raw["path"]
|
|
119
|
+
required = bool(raw.get("required", True) and raw.get("required_at_entry", True))
|
|
120
|
+
else:
|
|
121
|
+
continue
|
|
122
|
+
rendered = render_path(relative, feature, phase)
|
|
123
|
+
try:
|
|
124
|
+
exists = review_preflight.within(root, rendered).is_file()
|
|
125
|
+
except ValueError:
|
|
126
|
+
exists = False
|
|
127
|
+
input_facts.append({"path": rendered, "required": required, "exists": exists})
|
|
128
|
+
facts["inputs"] = input_facts
|
|
129
|
+
facts["outputs"] = [
|
|
130
|
+
render_path(item["path"], feature, phase)
|
|
131
|
+
for item in definition.get("outputs", [])
|
|
132
|
+
if isinstance(item, dict) and item.get("required") is True
|
|
133
|
+
and isinstance(item.get("path"), str)
|
|
134
|
+
]
|
|
135
|
+
except Exception as error:
|
|
136
|
+
issues.append({"code": "STARTUP_PREFLIGHT_INPUT_UNAVAILABLE", "detail": str(error)})
|
|
137
|
+
|
|
138
|
+
status = (
|
|
139
|
+
"core_contract_conflict" if contract_status == "core_contract_conflict"
|
|
140
|
+
else "human_confirmation" if issues
|
|
141
|
+
else "ready"
|
|
142
|
+
)
|
|
143
|
+
return {
|
|
144
|
+
"status": status,
|
|
145
|
+
"scope": SCOPE,
|
|
146
|
+
"facts": facts,
|
|
147
|
+
"handoff": handoff_result,
|
|
148
|
+
"evidence": evidence,
|
|
149
|
+
"issues": issues,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> int:
|
|
154
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
155
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
156
|
+
parser.add_argument("--feature", required=True)
|
|
157
|
+
parser.add_argument("--step", required=True)
|
|
158
|
+
parser.add_argument("--contract")
|
|
159
|
+
parser.add_argument("--handoff")
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
result = startup_preflight(args.root, args.feature, args.step, args.contract, args.handoff)
|
|
162
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
163
|
+
return 0 if result["status"] == "ready" else 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
|
@@ -55,6 +55,11 @@ module WorkflowStateValidator
|
|
|
55
55
|
runtime_integrity_error
|
|
56
56
|
acceptance_tests_not_passed
|
|
57
57
|
acceptance_tests_empty
|
|
58
|
+
acceptance_test_suite_freeze_failed
|
|
59
|
+
acceptance_test_suite_freeze_attestation_invalid
|
|
60
|
+
acceptance_test_suite_freeze_attestation_failed
|
|
61
|
+
acceptance_test_suite_candidate_binding_failed
|
|
62
|
+
acceptance_test_suite_freeze_recovery_failed
|
|
58
63
|
].freeze
|
|
59
64
|
|
|
60
65
|
def self.hard_validation_code?(code)
|