@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.
Files changed (38) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +697 -91
  4. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  21. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  22. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  23. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  24. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +1015 -0
  25. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  26. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  27. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +12 -2
  29. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  30. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  31. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  34. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +72 -0
  35. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  36. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
  37. package/src/constants.ts +1 -1
  38. 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[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
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
- errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
317
- errors << "#{node["id"]}: requirement_refs must be a non-empty array" unless data["requirement_refs"].is_a?(Array) && !data["requirement_refs"].empty?
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
- applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
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)