@guanlin4924/devflow-pi 0.1.40 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  44. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  45. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  47. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  48. package/src/constants.ts +1 -1
  49. package/src/runtime.ts +53 -22
@@ -27,6 +27,7 @@ options = {
27
27
  expected_revision: nil,
28
28
  json: false,
29
29
  runtime_confirmed: false,
30
+ confirm_test_prerequisites: false,
30
31
  parallel_action: nil,
31
32
  parallel_branch_id: nil,
32
33
  parallel_attempt: nil,
@@ -48,6 +49,7 @@ OptionParser.new do |opts|
48
49
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
49
50
  opts.on("--json", "Print JSON output") { options[:json] = true }
50
51
  opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
52
+ opts.on("--confirm-test-prerequisites", "Confirm displayed CP-3 prerequisite candidates in the same atomic freeze") { options[:confirm_test_prerequisites] = true }
51
53
  opts.on("--parallel-action ACTION", %w[start complete fail retry join]) { |value| options[:parallel_action] = value }
52
54
  opts.on("--parallel-branch-id ID", "Branch id for a parallel transition") { |value| options[:parallel_branch_id] = value }
53
55
  opts.on("--parallel-task-id ID", "OMP task id to bind to a branch") { |value| options[:parallel_task_id] = value }
@@ -94,6 +96,8 @@ def transition_gate_contract(workflow, step)
94
96
  window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
95
97
  end
96
98
  allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
99
+ step_order = step["order"].to_i
100
+ immediate_prior = Array(workflow["steps"]).select { |candidate| candidate.is_a?(Hash) && candidate["order"].to_i < step_order }.max_by { |candidate| candidate["order"].to_i }
97
101
  reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
98
102
  next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
99
103
  entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
@@ -104,6 +108,7 @@ def transition_gate_contract(workflow, step)
104
108
  "topology" => {
105
109
  "allowed_tracks" => allowed_tracks,
106
110
  "allowed_predecessors" => Array(step["depends_on"]),
111
+ "allowed_intervening_predecessors" => immediate_prior ? [immediate_prior["id"]] : [],
107
112
  "allowed_reentry" => reentries,
108
113
  "checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
109
114
  },
@@ -465,18 +470,59 @@ def join_parallel_window(root, state, workflow, current_revision)
465
470
  branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
466
471
  state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
467
472
  state["pending_steps"] = Array(state["pending_steps"]) - branch_steps
468
- state["last_completed_step"] = state["current_step"]
473
+ window = workflow.fetch("parallel_windows").find { |candidate| candidate["id"].to_s == record["workflow_window"].to_s }
474
+ raise ArgumentError, "parallel workflow window not found" unless window
475
+ join_step_id = window.fetch("join_step")
476
+ join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == join_step_id.to_s }
477
+ join_predecessors = Array(join_step && join_step["depends_on"]).map(&:to_s)
478
+ # The join step is only becoming current here; it has not completed yet.
479
+ # Keep last_completed_step on an actual branch predecessor so the next
480
+ # GateContract evaluation does not reject the join step as its own previous
481
+ # step. Prefer the final declared dependency for deterministic behavior.
482
+ last_predecessor = join_predecessors.reverse.find { |step_id| branch_steps.include?(step_id) }
483
+ state["last_completed_step"] = last_predecessor || branch_steps.last || state["last_completed_step"]
469
484
  state.delete("parallel_run_ref")
470
485
  record["status"] = "completed"
471
486
  record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
472
487
  record["updated_at"] = Time.now.utc.iso8601
473
- state["current_step"] = workflow.fetch("parallel_windows").find { |window| window["id"].to_s == record["workflow_window"].to_s }.fetch("join_step")
488
+ state["current_step"] = join_step_id
474
489
  state["status"] = "in_progress"
475
490
  state["state_updated_at"] = Date.today.to_s
476
491
  state["revision"] = current_revision + 1
477
492
  [state, record, "Parallel window joined; branch steps merged and parallel_run_ref cleared."]
478
493
  end
479
494
 
495
+ # Releases states written by the pre-fix join implementation. That version set
496
+ # last_completed_step to the join step while leaving the join step pending,
497
+ # making GateContract reject the very next complete-step call. The signature
498
+ # below is deliberately narrow so unrelated historical state is never changed.
499
+ def repair_legacy_parallel_join_predecessor!(state, workflow)
500
+ return nil if state["parallel_run_ref"]
501
+
502
+ current_step = state["current_step"].to_s
503
+ completed = Array(state["completed_steps"]).map(&:to_s)
504
+ return nil if current_step.empty? || completed.include?(current_step)
505
+ return nil unless state["last_completed_step"].to_s == current_step
506
+
507
+ window = Array(workflow["parallel_windows"]).find { |candidate| candidate["join_step"].to_s == current_step }
508
+ return nil unless window
509
+
510
+ branch_steps = if window["branches"].is_a?(Hash)
511
+ window["branches"].values.flatten.map(&:to_s)
512
+ else
513
+ Array(window["branch_steps"]).map(&:to_s)
514
+ end
515
+ return nil if branch_steps.empty? || branch_steps.any? { |step_id| !completed.include?(step_id) }
516
+
517
+ join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == current_step }
518
+ predecessor = Array(join_step && join_step["depends_on"]).map(&:to_s).reverse.find { |step_id| completed.include?(step_id) }
519
+ predecessor ||= branch_steps.reverse.find { |step_id| completed.include?(step_id) }
520
+ return nil unless predecessor
521
+
522
+ state["last_completed_step"] = predecessor
523
+ predecessor
524
+ end
525
+
480
526
  def fail_result(reason, details = {})
481
527
  { "ok" => false, "reason" => reason, "details" => details }
482
528
  end
@@ -540,6 +586,32 @@ rescue JSON::ParserError => error
540
586
  { "ok" => false, "errors" => ["cross-track checker returned invalid JSON: #{error.message}"] }
541
587
  end
542
588
 
589
+ def business_cross_model_checker_enabled?(workflow, step_id)
590
+ Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("business-cross-model-check")
591
+ end
592
+
593
+ def business_cross_model_check(root, feature)
594
+ script = File.join(__dir__, "business_cross_model_check.py")
595
+ business = ".planning/business-model/#{feature}/canonical.json"
596
+ wireframe = ".planning/features/#{feature}/canonical.json"
597
+ unless File.file?(File.join(root, business))
598
+ return { "valid" => false, "errors" => [{ "code" => "BUSINESS_CANONICAL_MISSING", "path" => business }] }
599
+ end
600
+ command = ["python3", script, "--business-canonical", business]
601
+ command += ["--wireframe", wireframe] if File.file?(File.join(root, wireframe))
602
+ claims = ".planning/features/#{feature}/business-claims.yaml"
603
+ command += ["--claims", claims] if File.file?(File.join(root, claims))
604
+ traceability = ".planning/requirements/#{feature}/traceability.json"
605
+ command += ["--traceability", traceability] if File.file?(File.join(root, traceability))
606
+ stdout, stderr, status = Open3.capture3(*command, "--json", chdir: root)
607
+ payload = JSON.parse(stdout)
608
+ return payload if status.success? && payload.is_a?(Hash)
609
+
610
+ payload.is_a?(Hash) ? payload : { "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_FAILED", "message" => stderr.strip }] }
611
+ rescue JSON::ParserError => error
612
+ { "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_INVALID", "message" => error.message }] }
613
+ end
614
+
543
615
  def branch_machine_evidence(root, state, branch)
544
616
  script = File.join(__dir__, "machine_evidence.rb")
545
617
  step = branch.fetch("workflow_step").to_s
@@ -668,9 +740,16 @@ result =
668
740
  actions = []
669
741
  action_description = nil
670
742
  parallel_run_to_write = nil
743
+ test_suite_freeze_to_write = nil
671
744
  next_intent_requested = false
672
745
  current_revision = Integer(state.fetch("revision", 0))
673
746
 
747
+ if (repaired_predecessor = repair_legacy_parallel_join_predecessor!(state, workflow))
748
+ changed = true
749
+ actions << "legacy_parallel_join_predecessor_repaired"
750
+ action_description = "Repaired legacy parallel join predecessor: #{repaired_predecessor}."
751
+ end
752
+
674
753
  if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
675
754
  result = fail_result(
676
755
  "stale_revision",
@@ -772,6 +851,12 @@ result =
772
851
  result = fail_result("cross_track_consistency_failed", "checker" => checker)
773
852
  end
774
853
  end
854
+ if result.nil? && business_cross_model_checker_enabled?(workflow, "integration-verify")
855
+ checker = business_cross_model_check(root, state.fetch("current_feature"))
856
+ unless checker["valid"] == true
857
+ result = fail_result("business_cross_model_check_failed", "checker" => checker)
858
+ end
859
+ end
775
860
  if result.nil?
776
861
  state, parallel_run_to_write, action_description = join_parallel_window(root, state, workflow, current_revision)
777
862
  changed = true
@@ -972,21 +1057,25 @@ result =
972
1057
  unless candidate_check["ok"]
973
1058
  result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
974
1059
  else
975
- frozen = RequirementGraph.freeze_test_cases!(root, graph, "CP-3: state revision #{current_revision} approved")
1060
+ frozen = RequirementGraph.freeze_test_cases!(
1061
+ root,
1062
+ graph,
1063
+ "CP-3: state revision #{current_revision} approved",
1064
+ confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
1065
+ persist: false
1066
+ )
976
1067
  unless frozen["ok"]
977
1068
  result = fail_result("acceptance_test_suite_freeze_failed", "test_suite" => frozen)
978
1069
  else
979
- begin
980
- write_test_suite_freeze_attestation(root, feature, current_revision, candidate_check, frozen)
981
- frozen_check = test_task_binding_check(root, feature, "frozen")
982
- if frozen_check["ok"]
983
- actions << "acceptance_test_suite_frozen"
984
- else
985
- result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
986
- end
987
- rescue DevflowControlIO::InvalidControlPath, SystemCallError => error
988
- result = fail_result("acceptance_test_suite_freeze_attestation_failed", "message" => error.message)
989
- end
1070
+ actions << "acceptance_test_prerequisites_confirmed" unless Array(frozen["confirmed_prerequisite_ids"]).empty?
1071
+ test_suite_freeze_to_write = {
1072
+ "feature" => feature,
1073
+ "state_revision" => current_revision,
1074
+ "graph" => graph,
1075
+ "candidate_check" => candidate_check,
1076
+ "frozen" => frozen
1077
+ }
1078
+ actions << "acceptance_test_suite_frozen"
990
1079
  end
991
1080
  end
992
1081
  end
@@ -1102,23 +1191,49 @@ result =
1102
1191
  if !ownership["ok"]
1103
1192
  result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
1104
1193
  else
1105
- sync_artifact_registry(root, workflow, state, registry: candidate_registry)
1106
- artifact_by_path = load_artifact_registry(root, workflow)
1107
- write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
1108
- ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
1109
- # STATE.md is project-global. Once its top-level workflow is
1110
- # terminal, retain the historical owner but release its active
1111
- # reservation so the next feature can claim a distinct phase.
1112
- if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
1113
- completed_owner = PhaseOwnership.complete(
1114
- root: root, feature: state["current_feature"], phase: state["current_phase"],
1115
- flow: state["current_flow"], write: true
1194
+ if test_suite_freeze_to_write
1195
+ pending_freeze = test_suite_freeze_to_write
1196
+ RequirementGraph.persist_test_suite_freeze!(
1197
+ root,
1198
+ pending_freeze.fetch("graph"),
1199
+ pending_freeze.fetch("frozen").fetch("audit_event")
1200
+ )
1201
+ pending_freeze.fetch("frozen")["tests_view"] = pending_freeze.fetch("graph").dig("views", "tests")
1202
+ write_test_suite_freeze_attestation(
1203
+ root,
1204
+ pending_freeze.fetch("feature"),
1205
+ pending_freeze.fetch("state_revision"),
1206
+ pending_freeze.fetch("candidate_check"),
1207
+ pending_freeze.fetch("frozen")
1116
1208
  )
1117
- unless completed_owner["ok"]
1118
- result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
1209
+ frozen_check = test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1210
+ unless frozen_check["ok"]
1211
+ result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1212
+ end
1213
+ end
1214
+ unless result
1215
+ sync_artifact_registry(root, workflow, state, registry: candidate_registry)
1216
+ artifact_by_path = load_artifact_registry(root, workflow)
1217
+ write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
1218
+ ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
1219
+ ParallelRun.rebase_state_revision(
1220
+ root: root, feature: state.fetch("current_feature"),
1221
+ state_revision: state.fetch("revision"), lock: project_lock
1222
+ )
1223
+ # STATE.md is project-global. Once its top-level workflow is
1224
+ # terminal, retain the historical owner but release its active
1225
+ # reservation so the next feature can claim a distinct phase.
1226
+ if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
1227
+ completed_owner = PhaseOwnership.complete(
1228
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
1229
+ flow: state["current_flow"], write: true
1230
+ )
1231
+ unless completed_owner["ok"]
1232
+ result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
1233
+ end
1119
1234
  end
1235
+ next_intent_written = write_next_intent(root, workflow) if next_intent_requested
1120
1236
  end
1121
- next_intent_written = write_next_intent(root, workflow) if next_intent_requested
1122
1237
  end
1123
1238
  end
1124
1239
 
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Validate business/technical claims against frozen business facts."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def load_yaml(path: Path) -> Any:
13
+ try:
14
+ import yaml # type: ignore
15
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
16
+ except ImportError:
17
+ result = subprocess.run(["ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))", str(path)], text=True, capture_output=True, check=False)
18
+ if result.returncode != 0:
19
+ raise ValueError(result.stderr.strip() or "YAML parse failed")
20
+ return json.loads(result.stdout)
21
+
22
+
23
+ def main() -> int:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--claims", type=Path, required=True)
26
+ parser.add_argument("--business-canonical", type=Path, required=True)
27
+ parser.add_argument("--traceability", type=Path)
28
+ args = parser.parse_args()
29
+ try:
30
+ claims = load_yaml(args.claims)
31
+ business = json.loads(args.business_canonical.read_text(encoding="utf-8"))
32
+ traceability = json.loads(args.traceability.read_text(encoding="utf-8")) if args.traceability else {}
33
+ except Exception as error:
34
+ print(json.dumps({"valid": False, "verdict": "block_unsafe", "errors": [{"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "message": str(error)}]}, ensure_ascii=False))
35
+ return 1
36
+ errors = []
37
+ warnings = []
38
+ actions = {item.get("id") for item in business.get("actions", []) if isinstance(item, dict)}
39
+ requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}} if isinstance(traceability, dict) else set()
40
+ values = claims.get("claims") if isinstance(claims, dict) else claims
41
+ if not isinstance(values, list):
42
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": "claims", "message": "claims must be an array"})
43
+ values = []
44
+ for index, claim in enumerate(values):
45
+ path = f"claims[{index}]"
46
+ if not isinstance(claim, dict):
47
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "claim must be an object"})
48
+ continue
49
+ allowed = {"id", "text", "kind", "business_action_ref", "requirement_ref"}
50
+ for key in claim:
51
+ if key not in allowed:
52
+ errors.append({"code": "BUSINESS_CLAIM_UNKNOWN_FIELD", "path": f"{path}.{key}", "message": "unknown claim field"})
53
+ if not isinstance(claim.get("id"), str) or not claim["id"].strip() or not isinstance(claim.get("text"), str) or not claim["text"].strip():
54
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "id and text are required"})
55
+ if isinstance(claim.get("id"), str) and sum(1 for item in values[:index] if isinstance(item, dict) and item.get("id") == claim.get("id")):
56
+ errors.append({"code": "BUSINESS_CLAIM_DUPLICATE_ID", "path": f"{path}.id", "message": f"duplicate claim id {claim['id']}"})
57
+ kind = claim.get("kind")
58
+ if kind not in {"business", "technical"}:
59
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": f"{path}.kind", "message": "kind must be business or technical"})
60
+ ref = claim.get("business_action_ref")
61
+ req = claim.get("requirement_ref")
62
+ if ref and ref not in actions:
63
+ errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.business_action_ref", "message": f"unknown business action {ref}"})
64
+ if req and (not args.traceability or req not in requirements):
65
+ errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.requirement_ref", "message": f"requirement is not confirmed or traceability input is missing: {req}"})
66
+ if kind == "business" and not ref and not req:
67
+ errors.append({"code": "BUSINESS_CLAIM_REF_MISSING", "path": path, "message": "business claim requires action or requirement reference"})
68
+ if kind == "technical" and not ref and not req:
69
+ warnings.append({"code": "TECHNICAL_CLAIM_UNGROUNDED", "path": path, "message": "technical claim has no business grounding reference"})
70
+ verdict = "block_unsafe" if errors else ("await_user" if warnings else "allow")
71
+ payload = {"valid": not errors, "verdict": verdict, "errors": errors, "warnings": warnings}
72
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
73
+ return 0 if verdict in {"allow", "await_user"} else 1
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())