@guanlin4924/devflow-pi 0.1.105 → 0.1.106

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 (62) hide show
  1. package/README.md +10 -8
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +105 -305
  4. package/plugins/devflow-core/runtime/hooks/context_sync_gate.py +3 -1
  5. package/plugins/devflow-core/runtime/hooks/hook_common.py +6 -5
  6. package/plugins/devflow-core/runtime/hooks/index_gate.py +19 -26
  7. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +2 -0
  8. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +2 -0
  9. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +2 -0
  10. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +34 -50
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +40 -191
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/artifact_specs.rb +12 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +38 -1
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_policy.rb +11 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +12 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +4 -9
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +13 -86
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +24 -13
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +4 -4
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +3 -1
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +8 -39
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +39 -3
  24. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +2 -3
  25. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +0 -1
  26. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +10 -5
  27. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +1 -1
  28. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +2 -0
  29. package/plugins/devflow-design/skills/workflow-api-call-guide/SKILL.md +2 -0
  30. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +3 -1
  31. package/plugins/devflow-design/skills/workflow-api-supplement/SKILL.md +2 -0
  32. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +2 -0
  33. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +6 -4
  34. package/plugins/devflow-design/skills/workflow-design-rule-pack/SKILL.md +2 -0
  35. package/plugins/devflow-design/skills/workflow-feature-breakdown/SKILL.md +2 -0
  36. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -4
  37. package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +3 -1
  38. package/plugins/devflow-design/skills/workflow-ui-spec/SKILL.md +4 -2
  39. package/plugins/devflow-implementation/agents/implementation-worker.md +2 -8
  40. package/plugins/devflow-implementation/skills/workflow-bug-fix/SKILL.md +2 -0
  41. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +3 -1
  42. package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +3 -1
  43. package/plugins/devflow-intel/skills/code-index-query/SKILL.md +2 -0
  44. package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +2 -0
  45. package/plugins/devflow-intel/skills/workflow-context-sync/SKILL.md +2 -0
  46. package/plugins/devflow-intel/skills/workflow-update-navigation/SKILL.md +3 -1
  47. package/plugins/devflow-quality/skills/code-review/SKILL.md +4 -2
  48. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -1
  49. package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +2 -0
  50. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  51. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +2 -0
  52. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +2 -0
  53. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +7 -5
  54. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +8 -6
  55. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +2 -0
  56. package/plugins/devflow-requirements/skills/workflow-demand-clarify/SKILL.md +2 -0
  57. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +2 -0
  58. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +6 -2
  59. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +2 -0
  60. package/plugins/devflow-requirements/skills/workflow-wayfinder/SKILL.md +2 -0
  61. package/src/constants.ts +1 -1
  62. package/templates/omp/AGENTS.md +6 -2
@@ -9,6 +9,8 @@ require "open3"
9
9
  require "optparse"
10
10
  require "yaml"
11
11
  require_relative "control_io"
12
+ require_relative "artifact_specs"
13
+ require_relative "gate_policy"
12
14
  require_relative "validate_state"
13
15
  require_relative "phase_ownership"
14
16
  require_relative "requirement_graph"
@@ -84,9 +86,7 @@ def gate_contract_enabled?(workflow)
84
86
  workflow.dig("governance", "gate_contract_version").to_i >= 3
85
87
  end
86
88
 
87
- def core_hard_gate_mode(workflow)
88
- workflow.dig("governance", "hard_gate_mode").to_s.empty? ? "chain" : workflow.dig("governance", "hard_gate_mode").to_s
89
- end
89
+
90
90
 
91
91
  # Build the runtime view from the existing workflow declaration. This keeps
92
92
  # governance.step_checkers as the single checker namespace and avoids creating
@@ -125,7 +125,6 @@ def transition_gate_contract(workflow, step)
125
125
  "required_inputs" => Array(step["inputs"]).select { |input| input.is_a?(Hash) && input["required_at_completion"] == true }
126
126
  },
127
127
  "quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
128
- "hard_gate_mode" => core_hard_gate_mode(workflow),
129
128
  "concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
130
129
  "recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
131
130
  }
@@ -419,19 +418,7 @@ def track_allowed_steps(track)
419
418
  end
420
419
 
421
420
  def next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
422
- track = active_track(state)
423
- unless track
424
- return pending_ids.find do |step_id|
425
- step_by_id[step_id] && dependencies_satisfied?(step_by_id[step_id], completed_ids)
426
- end
427
- end
428
-
429
- allowed = track_allowed_steps(track)
430
- pending_ids.find do |step_id|
431
- allowed.include?(step_id) &&
432
- step_by_id[step_id] &&
433
- dependencies_satisfied?(step_by_id[step_id], completed_ids)
434
- end
421
+ pending_ids.find { |id| step_by_id[id] && dependencies_satisfied?(step_by_id[id], completed_ids) } || pending_ids.find { |id| step_by_id[id] }
435
422
  end
436
423
 
437
424
  def conditional_skip_policies(workflow, checkpoint_id)
@@ -535,7 +522,11 @@ def join_parallel_window(root, state, workflow, current_revision)
535
522
  raise ArgumentError, "parallel run not found" unless record
536
523
  branches = Array(record["branches"])
537
524
  incomplete = branches.reject { |branch| branch["status"].to_s == "succeeded" }
538
- raise ArgumentError, "parallel join requires every branch to succeed" unless branches.any? && incomplete.empty?
525
+ raise ArgumentError, "parallel run has no branches" if branches.empty?
526
+ if incomplete.any?
527
+ $devflow_advisories << { "reason" => "parallel_branches_not_succeeded", "branches" => incomplete.map { |branch| branch.slice("branch_id", "status") } }
528
+ record["completion_with_warnings"] = true
529
+ end
539
530
 
540
531
  branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
541
532
  state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
@@ -597,45 +588,19 @@ end
597
588
  # a small safety floor for malformed control-plane state and unsafe writes, but
598
589
  # Core-generated test/traceability/merge findings no longer strand the serial
599
590
  # workflow before the AI review can decide what to do.
600
- SOFT_GATE_MODE = ENV.fetch("DEVFLOW_SOFT_GATES", "1") != "0"
601
- HARD_FAILURE_REASONS = %w[
602
- missing_state_file
603
- missing_state_front_matter
604
- stale_revision
605
- invalid_control_path
606
- unknown_step
607
- gate_target_step_unknown
608
- gate_current_step_mismatch
609
- parallel_transition_requires_write
610
- feature_not_active
611
- cancel_reason_required
612
- session_rollover_in_progress
613
- failure_branch_incomplete
614
- parallel_machine_evidence_failed
615
- parallel_join_failed
616
- parallel_branch_transition_failed
617
- conditional_step_skip_failed
618
- cross_track_consistency_failed
619
- business_cross_model_check_failed
620
- gate_boundary_rejected
621
- acceptance_results_not_passed
622
- execution_surface_mismatch
623
- approval_invalid
624
- human_result_unapproved
625
- dsl_not_approved
626
- environment_profile_not_approved
627
- runtime_integrity_error
628
- acceptance_test_suite_freeze_failed
629
- acceptance_test_suite_freeze_attestation_invalid
630
- acceptance_test_suite_freeze_attestation_failed
631
- acceptance_test_suite_candidate_binding_failed
632
- acceptance_test_suite_freeze_recovery_failed
591
+ OPERATION_FAILURE_REASONS = %w[
592
+ missing_state_file missing_state_front_matter stale_revision invalid_control_path
593
+ unknown_step gate_target_step_unknown gate_current_step_mismatch
594
+ parallel_transition_requires_write feature_not_active cancel_reason_required
595
+ session_rollover_in_progress parallel_branch_not_found gate_boundary_rejected
596
+ parallel_join_failed parallel_branch_transition_failed state_validation_failed
597
+ phase_ownership_claim_failed phase_ownership_completion_failed not_waiting_checkpoint no_pending_step
633
598
  ].freeze
634
599
  $devflow_advisories = []
635
600
 
636
601
  def fail_result(reason, details = {})
637
602
  failure = { "ok" => false, "reason" => reason, "details" => details }
638
- if SOFT_GATE_MODE && !HARD_FAILURE_REASONS.include?(reason.to_s)
603
+ if !OPERATION_FAILURE_REASONS.include?(reason.to_s)
639
604
  $devflow_advisories << failure
640
605
  return nil
641
606
  end
@@ -660,8 +625,8 @@ rescue JSON::ParserError => error
660
625
  { "ok" => false, "reason" => "test_task_binding_#{stage}_check_failed", "errors" => ["checker returned invalid JSON: #{error.message}"] }
661
626
  end
662
627
 
663
- # Acceptance results are a hard gate even in the default soft mode: Core itself
664
- # re-validates the frozen-suite results instead of trusting a checker summary.
628
+ # Always re-check acceptance results. Soft mode retains failures as advisories
629
+ # without changing the canonical test verdict.
665
630
  ACCEPTANCE_RESULT_GATED_STEPS = %w[run-acceptance-tests release-closeout].freeze
666
631
 
667
632
  def acceptance_results_check(root, feature)
@@ -956,7 +921,9 @@ result =
956
921
  !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
957
922
  (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
958
923
  topology_errors = WorkflowGateContract.validate(workflow)
959
- unless topology_errors.empty?
924
+ if !topology_errors.empty?
925
+ $devflow_advisories.concat(topology_errors)
926
+ elsif !topology_errors.empty?
960
927
  result = fail_result("gate_boundary_rejected", "gate" => {
961
928
  "verdict" => "deny",
962
929
  "reason" => "workflow_gate_contract_invalid",
@@ -1014,7 +981,8 @@ result =
1014
981
  transition_kind: transition_kind,
1015
982
  checkpoint_authorized: options[:runtime_confirmed]
1016
983
  )
1017
- if gate_result && gate_result["verdict"] != "allow"
984
+ $devflow_advisories.concat(Array(gate_result && gate_result["warnings"]))
985
+ if gate_result && !gate_result["ok"]
1018
986
  result = fail_result("gate_boundary_rejected", "gate" => gate_result)
1019
987
  end
1020
988
  end
@@ -1129,24 +1097,22 @@ result =
1129
1097
  state.delete("checkpoint_decision")
1130
1098
 
1131
1099
  if step["checkpoint"]
1132
- state["current_step"] = step_id
1133
- state["status"] = "waiting_checkpoint"
1134
- state["checkpoint"] = step["checkpoint"]
1135
- state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
1136
- action_description = "Step completed: #{step["label"]} (#{step_id}). Now waiting at #{step["checkpoint"]}."
1137
- elsif (parallel_window = parallel_window_for(workflow, step_id))
1100
+ $devflow_advisories << { "reason" => "checkpoint_advisory", "checkpoint" => step["checkpoint"], "approval_recorded" => false }
1101
+ state["checkpoint_decision"] = "continued_with_warning"
1102
+ begin
1103
+ apply_conditional_step_skips!(root, state, workflow, step["checkpoint"], current_revision + 1)
1104
+ pending = Array(state["pending_steps"])
1105
+ completed = Array(state["completed_steps"])
1106
+ rescue JSON::ParserError, KeyError, ArgumentError => error
1107
+ $devflow_advisories << { "reason" => "conditional_step_skip_failed", "message" => error.message }
1108
+ end
1109
+ end
1110
+ if (parallel_window = parallel_window_for(workflow, step_id))
1138
1111
  state["current_step"] = parallel_window.fetch("join_step")
1139
1112
  state["status"] = "in_progress"
1140
1113
  state.delete("checkpoint")
1141
1114
  state.delete("checkpoint_label")
1142
1115
  action_description = "Step completed: #{step["label"]} (#{step_id}). Entered parallel window #{parallel_window["id"]}; awaiting Core prepare."
1143
- elsif track_boundary?(state, pending)
1144
- state["current_step"] = step_id
1145
- state["status"] = "blocked"
1146
- state["block_reason"] = "track_scope_complete"
1147
- state.delete("checkpoint")
1148
- state.delete("checkpoint_label")
1149
- action_description = "Step completed: #{step["label"]} (#{step_id}). Active track output scope is complete."
1150
1116
  elsif (next_id = next_pending_step_id(state, pending, step_by_id, completed))
1151
1117
  state["current_step"] = next_id
1152
1118
  state["status"] = "in_progress"
@@ -1178,125 +1144,6 @@ result =
1178
1144
  elsif Array(state["pending_steps"]).empty?
1179
1145
  result = fail_result("no_pending_step")
1180
1146
  else
1181
- # CP-API turns the shared candidate contract into confirmed graph
1182
- # fact inside the same user checkpoint. Skills cannot self-confirm
1183
- # API nodes and downstream generated TC imports can require a
1184
- # reviewed contract revision.
1185
- if state["checkpoint"] == "CP-API" && steps.any? { |step| step["id"] == "api-contract" }
1186
- feature = state.fetch("current_feature")
1187
- graph, = RequirementGraph.load_graph(root, feature)
1188
- schema_check = api_test_schema_check(root, feature, graph)
1189
- if !schema_check["ok"]
1190
- result = fail_result("api_test_schema_incomplete", "api_test_schema" => schema_check)
1191
- else
1192
- api_confirmation = RequirementGraph.confirm_api_contracts!(root, graph, "CP-API: state revision #{current_revision} approved")
1193
- if api_confirmation["ok"]
1194
- actions << "api_contract_confirmed" unless Array(api_confirmation["confirmed_api_ids"]).empty?
1195
- else
1196
- result = fail_result("api_contract_confirmation_failed", "api_contract" => api_confirmation)
1197
- end
1198
- end
1199
- end
1200
-
1201
- # CP-3 authorizes exactly one immutable TC freeze. It is deliberately
1202
- # inside the checkpoint transition so the user does not have to
1203
- # approve an extra model-issued dialog and a Skill cannot freeze its
1204
- # own candidates early.
1205
- if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| %w[acceptance-test-design regression-test-design].include?(step["id"]) }
1206
- feature = state.fetch("current_feature")
1207
- graph, = RequirementGraph.load_graph(root, feature)
1208
- regression = regression_workflow?(state, steps)
1209
- attestation_path, = test_suite_freeze_attestation_path(root, feature)
1210
-
1211
- if File.exist?(attestation_path)
1212
- frozen_check = if regression
1213
- regression_freeze_attestation_check(graph, feature, attestation_path)
1214
- else
1215
- test_task_binding_check(root, feature, "frozen")
1216
- end
1217
- unless frozen_check["ok"]
1218
- result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1219
- else
1220
- actions << "acceptance_test_suite_freeze_attestation_verified"
1221
- end
1222
- elsif cp3_freeze_provenance?(graph, current_revision)
1223
- # A process can die after graph/view writes but before the
1224
- # attestation is published. Recover only if each TC proves that
1225
- # this exact CP-3 already froze it; never use this path to bless
1226
- # a generic confirmed suite.
1227
- recovery_check = if regression
1228
- { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1229
- else
1230
- test_task_binding_check(root, feature, "recovery")
1231
- end
1232
- unless recovery_check["ok"]
1233
- result = fail_result("acceptance_test_suite_freeze_recovery_failed", "test_suite" => recovery_check)
1234
- else
1235
- recovered = {
1236
- "ok" => true,
1237
- "tests_view" => graph.dig("views", "tests"),
1238
- "frozen_case_ids" => Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }.map { |node| node["id"] }
1239
- }
1240
- begin
1241
- write_test_suite_freeze_attestation(root, feature, current_revision, recovery_check, recovered)
1242
- frozen_check = if regression
1243
- regression_freeze_attestation_check(graph, feature, attestation_path)
1244
- else
1245
- test_task_binding_check(root, feature, "frozen")
1246
- end
1247
- if frozen_check["ok"]
1248
- actions << "acceptance_test_suite_freeze_attestation_recovered"
1249
- else
1250
- result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1251
- end
1252
- rescue DevflowControlIO::InvalidControlPath, SystemCallError => error
1253
- result = fail_result("acceptance_test_suite_freeze_attestation_failed", "message" => error.message)
1254
- end
1255
- end
1256
- else
1257
- matrix_check = api_test_matrix_check(root, feature, graph)
1258
- # A new freeze requires every TC to declare a consistent
1259
- # execution_surface; this is a hard failure even in soft mode.
1260
- surface_check = matrix_check["ok"] ? execution_surface_check(root, feature) : nil
1261
- unless matrix_check["ok"]
1262
- result = fail_result("api_test_matrix_incomplete", "api_test_matrix" => matrix_check)
1263
- else
1264
- result = fail_result("execution_surface_mismatch", "execution_surface" => surface_check) unless surface_check["ok"]
1265
- end
1266
- if matrix_check["ok"] && surface_check["ok"]
1267
- candidate_check = if regression
1268
- { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1269
- else
1270
- test_task_binding_check(root, feature, "candidate")
1271
- end
1272
- unless candidate_check["ok"]
1273
- result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
1274
- else
1275
- frozen = RequirementGraph.freeze_test_cases!(
1276
- root,
1277
- graph,
1278
- "CP-3: state revision #{current_revision} approved",
1279
- confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
1280
- regression: regression,
1281
- persist: false
1282
- )
1283
- unless frozen["ok"]
1284
- result = fail_result("acceptance_test_suite_freeze_failed", "test_suite" => frozen)
1285
- else
1286
- actions << "acceptance_test_prerequisites_confirmed" unless Array(frozen["confirmed_prerequisite_ids"]).empty?
1287
- test_suite_freeze_to_write = {
1288
- "feature" => feature,
1289
- "state_revision" => current_revision,
1290
- "graph" => graph,
1291
- "candidate_check" => candidate_check,
1292
- "frozen" => frozen
1293
- }
1294
- actions << "acceptance_test_suite_frozen"
1295
- end
1296
- end
1297
- end
1298
- end
1299
- end
1300
1147
  if result.nil?
1301
1148
  begin
1302
1149
  applied_skips = apply_conditional_step_skips!(root, state, workflow, state["checkpoint"], current_revision + 1)
@@ -1313,7 +1160,7 @@ result =
1313
1160
  completed = Array(state["completed_steps"])
1314
1161
  next_id = next_pending_step_id(state, pending, step_by_id, completed)
1315
1162
  if next_id.nil? && track_boundary?(state, pending)
1316
- state["checkpoint_decision"] = "approved"
1163
+ state["checkpoint_decision"] = "continued_with_warning"
1317
1164
  state["status"] = "blocked"
1318
1165
  state["block_reason"] = "track_scope_complete"
1319
1166
  state.delete("checkpoint")
@@ -1323,7 +1170,7 @@ result =
1323
1170
  action_description = "Checkpoint confirmed. Active track output scope is complete."
1324
1171
  next_intent_requested = true
1325
1172
  else
1326
- state["checkpoint_decision"] = "approved"
1173
+ state["checkpoint_decision"] = "continued_with_warning"
1327
1174
  state["current_step"] = next_id || pending.first
1328
1175
  state["status"] = "in_progress"
1329
1176
  state.delete("checkpoint")
@@ -1408,7 +1255,9 @@ result =
1408
1255
  phase_owner_override: prospective_owner
1409
1256
  )
1410
1257
 
1411
- if changed && !validation["ok"] && !SOFT_GATE_MODE
1258
+ $devflow_advisories.concat(Array(validation["warnings"]))
1259
+ state["gate_advisories"] = JSON.parse(JSON.generate(Array(state["gate_advisories"]) + $devflow_advisories)) if changed
1260
+ if changed && !validation["ok"]
1412
1261
  result = fail_result("state_validation_failed", "validation" => validation)
1413
1262
  elsif changed && options[:write]
1414
1263
  ownership = PhaseOwnership.claim(
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArtifactSpecs
4
+ module_function
5
+
6
+ def for_step(step)
7
+ outputs = Array(step["outputs"]).select { |entry| entry.is_a?(Hash) }.map(&:dup)
8
+ meta = step["meta_output"]
9
+ outputs << { "type" => "json" }.merge(meta).merge("required" => true) if meta.is_a?(Hash) && meta["path"]
10
+ outputs.uniq { |entry| entry["path"] }
11
+ end
12
+ end
@@ -10,6 +10,7 @@ require "yaml"
10
10
  require_relative "control_io"
11
11
  require_relative "requirement_graph"
12
12
  require_relative "checker_registry"
13
+ require_relative "parallel_run"
13
14
 
14
15
  POLICY = {
15
16
  "domain-modeling" => { "checkers" => %w[requirement-graph-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[term object behavior rule boundary] },
@@ -134,10 +135,46 @@ begin
134
135
  devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
135
136
  devflow_approval
136
137
  ],
137
- "tool_budget" => policy["budget"], "hard_gate_mode" => (workflow.dig("governance", "hard_gate_mode").to_s.empty? ? "chain" : workflow.dig("governance", "hard_gate_mode").to_s), "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
138
+ "tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
138
139
  "allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
139
140
  "on_unknown" => "block_and_escalate"
140
141
  }
142
+ # While a durable parallel run is attached, current_step is the join
143
+ # anchor, not permission to execute the join before its branches finish.
144
+ if state["parallel_run_ref"]
145
+ run = ParallelRun.load(root, options[:feature])
146
+ raise ArgumentError, "parallel run reference mismatch" unless run && run["run_id"] == state["parallel_run_ref"]
147
+ raise ArgumentError, "parallel run state revision mismatch" unless run["bound_state_revision"] == state["revision"]
148
+ window = Array(workflow["parallel_windows"]).find { |item| item["id"] == run["workflow_window"] }
149
+ raise ArgumentError, "parallel window unavailable" unless window && Array(state["completed_steps"]).include?(window["after_step"])
150
+ declared = window.fetch("branches", {}).values.flatten
151
+ branches = Array(run["branches"])
152
+ raise ArgumentError, "parallel run has no branches" if branches.empty?
153
+ branch_contracts = branches.map do |branch|
154
+ id = branch["workflow_step"]
155
+ raise ArgumentError, "parallel branch outside declared window" unless declared.include?(id)
156
+ definition = Array(workflow["steps"]).find { |item| item["id"] == id }
157
+ branch_policy = POLICY.fetch(id)
158
+ raise ArgumentError, "parallel branch definition unavailable" unless definition
159
+ {
160
+ "branch_id" => branch["branch_id"], "step" => id,
161
+ "allowed_agents" => branch_policy.fetch("agents"),
162
+ "allowed_paths" => (Array(definition["outputs"]).map { |output| output["path"] } + [definition.dig("meta_output", "path")]).compact.map { |value| interpolate(value, state) }.uniq,
163
+ "tool_budget" => branch_policy.fetch("budget"),
164
+ "allowed_graph_delta" => Array(branch_policy["allowed_graph_delta"])
165
+ }
166
+ end
167
+ contract.merge!(
168
+ "scope" => "parallel_window", "parallel_run_id" => run["run_id"],
169
+ "parallel_window_id" => window["id"], "join_step" => window["join_step"],
170
+ "branch_contracts" => branch_contracts,
171
+ "allowed_paths" => branch_contracts.flat_map { |item| item["allowed_paths"] }.uniq,
172
+ "allowed_agents" => branch_contracts.flat_map { |item| item["allowed_agents"] }.uniq,
173
+ "tool_budget" => branch_contracts.sum { |item| item["tool_budget"] },
174
+ "allowed_graph_delta" => branch_contracts.flat_map { |item| item["allowed_graph_delta"] }.uniq,
175
+ "checker_ids" => []
176
+ )
177
+ end
141
178
  frozen = contract.merge("frozen_hash" => Digest::SHA256.hexdigest(JSON.generate(contract)))
142
179
  contract_id = "EC-#{frozen["frozen_hash"][0, 16]}"
143
180
  frozen["contract_id"] = contract_id
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One policy for workflow diagnostics. Failed checks retain their evidence;
4
+ # advisory execution never changes a failed test/check into a passing result.
5
+ module GatePolicy
6
+ module_function
7
+
8
+ def advisory(entry)
9
+ entry.merge("severity" => "warning", "soft_gate" => true)
10
+ end
11
+ end
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "gate_policy"
5
+
4
6
  module GateResult
5
7
  VERDICTS = %w[allow allow_with_warning await_user waiting_external_input block_unsafe].freeze
6
8
  CONTROL_PLANE_ERROR = "control_plane_error"
@@ -11,6 +13,16 @@ module GateResult
11
13
  valid = VERDICTS.include?(verdict.to_s) || verdict.to_s == CONTROL_PLANE_ERROR
12
14
  raise ArgumentError, "invalid gate verdict: #{verdict}" unless valid
13
15
 
16
+ if !%w[allow allow_with_warning].include?(verdict.to_s)
17
+ codes = Array(errors).map { |entry| entry.is_a?(Hash) ? entry["code"].to_s : "" }
18
+ structural = codes.any? { |code| %w[REVISION_CONFLICT BUSINESS_GATE_POLICY_INVALID].include?(code) }
19
+ unless structural
20
+ warnings = Array(warnings) + Array(errors).map { |entry| GatePolicy.advisory(entry.is_a?(Hash) ? entry : { "message" => entry.to_s }) }
21
+ warnings << { "code" => "SOFT_GATE", "original_verdict" => verdict.to_s } if warnings.empty?
22
+ errors = []
23
+ verdict = "allow_with_warning"
24
+ end
25
+ end
14
26
  {
15
27
  "ok" => %w[allow allow_with_warning].include?(verdict.to_s),
16
28
  "verdict" => verdict.to_s,
@@ -32,14 +32,6 @@ module GateRuntime
32
32
  gate_id = contract.fetch("gate_id")
33
33
  step_id = contract.fetch("step_id")
34
34
  base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
35
- # During the chain-skill migration Core is diagnostic-only. It still
36
- # validates the contract shape above, but never blocks a transition on
37
- # graph, artifact, output, checker, review, or topology observations.
38
- # Checkpoint confirmation remains fail-closed because approval-path outputs
39
- # do not exist until the user approves the waiting checkpoint.
40
- if contract.fetch("hard_gate_mode", "core").to_s == "chain" && transition_kind.to_s != "confirm_checkpoint"
41
- return GateResult.allow(**base.merge(evidence: base.fetch(:evidence).merge("core_hard_gate_mode" => "chain", "core_hard_gate_bypassed" => true)))
42
- end
43
35
  return GateResult.block(**base, code: "REVISION_CONFLICT", message: "current revision does not match expected revision", recovery: { "kind" => "reinspect", "expected_revision" => expected_revision }) unless expected_revision.nil? || current_revision.to_i == expected_revision.to_i
44
36
  unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
45
37
  return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
@@ -191,10 +183,13 @@ module GateRuntime
191
183
 
192
184
  def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
193
185
  boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
194
- return boundary unless boundary["verdict"] == "allow"
186
+ return boundary unless boundary["ok"]
195
187
  result = checker.call
196
188
  raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
189
+ warnings = Array(boundary["warnings"]) + Array(result["warnings"])
197
190
  result.merge(
191
+ "warnings" => warnings,
192
+ "verdict" => result["verdict"] == "allow" && warnings.any? ? "allow_with_warning" : result["verdict"],
198
193
  "gate_id" => result["gate_id"] || boundary["gate_id"],
199
194
  "step_id" => result["step_id"] || boundary["step_id"],
200
195
  "evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
@@ -6,6 +6,7 @@ require "optparse"
6
6
  require "yaml"
7
7
  require_relative "validate_state"
8
8
  require_relative "checkpoint_meta"
9
+ require_relative "gate_policy"
9
10
 
10
11
  options = { json: false, state: nil }
11
12
  OptionParser.new do |opts|
@@ -192,19 +193,7 @@ def track_allowed_steps(track)
192
193
  end
193
194
 
194
195
  def next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
195
- track = active_track(state)
196
- unless track
197
- return pending_ids.find do |step_id|
198
- step_by_id[step_id] && dependencies_satisfied?(step_by_id[step_id], completed_ids)
199
- end
200
- end
201
-
202
- allowed = track_allowed_steps(track)
203
- pending_ids.find do |step_id|
204
- allowed.include?(step_id) &&
205
- step_by_id[step_id] &&
206
- dependencies_satisfied?(step_by_id[step_id], completed_ids)
207
- end
196
+ pending_ids.find { |id| step_by_id[id] && dependencies_satisfied?(step_by_id[id], completed_ids) } || pending_ids.find { |id| step_by_id[id] }
208
197
  end
209
198
 
210
199
  def track_boundary(state, pending_ids, completed_ids)
@@ -366,27 +355,12 @@ result =
366
355
 
367
356
  if missing.any?
368
357
  fail_result("missing_state_fields", fields: missing)
369
- elsif project_context_missing?(root)
370
- project_context_result(
371
- "incomplete_project_context",
372
- missing: [
373
- ".devflow/planning/PROJECT.md",
374
- ".devflow/planning/navigation/INDEX.md",
375
- ".devflow/context/spec/index.md"
376
- ]
377
- )
378
358
  else
379
359
  pending_template_blockers = Array(state["blockers"]).select do |blocker|
380
360
  blocker.is_a?(Hash) && blocker["type"] == "PENDING_TEMPLATE"
381
361
  end
382
362
 
383
- if pending_template_blockers.any?
384
- fail_result(
385
- "pending_depth_spec_templates",
386
- blockers: pending_template_blockers,
387
- recovery: "add the missing template or mark the topic NOT_APPLICABLE, then rerun workflow-project-context"
388
- )
389
- else
363
+ begin
390
364
  workflow_path = state["workflow_config"] ||
391
365
  File.join(".devflow/planning", "workflows", "#{state["current_flow"]}.yaml")
392
366
  abs_workflow_path = File.join(root, workflow_path)
@@ -413,6 +387,8 @@ result =
413
387
  strict_artifact_status: true
414
388
  )
415
389
 
390
+ validation["warnings"] << GatePolicy.advisory({ "code" => "incomplete_project_context", "message" => "project context indexes are missing" }) if project_context_missing?(root)
391
+ validation["warnings"].concat(pending_template_blockers.map { |entry| GatePolicy.advisory(entry) })
416
392
  if !validation["ok"]
417
393
  {
418
394
  current_flow: state["current_flow"],
@@ -429,6 +405,8 @@ result =
429
405
  },
430
406
  next_step: nil,
431
407
  workflow_config: workflow_path,
408
+ warnings: Array(state["gate_advisories"]) + Array(validation["warnings"]),
409
+ gate_mode: "soft",
432
410
  artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
433
411
  "path" => artifact_registry_path,
434
412
  "error" => artifact_registry_error,
@@ -446,41 +424,6 @@ result =
446
424
  },
447
425
  recovery: "Fix missing/invalid artifacts or call devflow_artifact with action=sync and preserve=false, then call devflow_state with action=inspect."
448
426
  }
449
- elsif (drift = validation["warnings"].find { |issue| issue["code"] == "current_output_registry_drift" })
450
- {
451
- current_flow: state["current_flow"],
452
- current_feature: state["current_feature"],
453
- current_phase: state["current_phase"],
454
- status: state["status"],
455
- completed_steps: completed_ids,
456
- pending_steps: pending_ids,
457
- current_step: current_step && {
458
- id: current_step["id"],
459
- label: current_step["label"],
460
- skill: current_step["skill"],
461
- action: current_step["action"]
462
- },
463
- next_step: nil,
464
- workflow_config: workflow_path,
465
- artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
466
- "path" => artifact_registry_path,
467
- "error" => artifact_registry_error,
468
- "current_outputs_registered" => 0
469
- ),
470
- action: "recover_artifact_registry",
471
- reason: "current_output_registry_drift",
472
- drift: drift,
473
- recovery_plan: {
474
- classification: "safe_repairable",
475
- state_transition: "none",
476
- registry_write: true,
477
- confirmation_required: true,
478
- expected_revision: state.fetch("revision", 0),
479
- diff: [{ path: drift["path"], registry_exists: drift["registry_exists"], filesystem_exists: true,
480
- registry_content_valid: drift["registry_content_valid"], filesystem_content_valid: true }]
481
- },
482
- recovery: "After reviewing this diff, call devflow_recovery(expectedRevision=#{state.fetch("revision", 0)}, confirm=true). It only reconciles registry metadata; explicitly complete the step separately when ready."
483
- }
484
427
  elsif current_step.nil?
485
428
  fallback_next_id = next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
486
429
  fallback_next_step = fallback_next_id && step_by_id[fallback_next_id]
@@ -604,6 +547,8 @@ result =
604
547
  },
605
548
  parallel_window: parallel_window,
606
549
  workflow_config: workflow_path,
550
+ warnings: Array(state["gate_advisories"]) + Array(validation["warnings"]),
551
+ gate_mode: "soft",
607
552
  artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
608
553
  "path" => artifact_registry_path,
609
554
  "error" => artifact_registry_error,
@@ -614,26 +559,7 @@ result =
614
559
 
615
560
  boundary = track_boundary(state, pending_ids, completed_ids)
616
561
 
617
- if artifact_registry_error
618
- base.merge(
619
- action: "blocked",
620
- reason: artifact_registry_error,
621
- recovery: "Call devflow_artifact with action=sync."
622
- )
623
- elsif boundary
624
- base.merge(
625
- action: "blocked",
626
- reason: boundary["reason"],
627
- message: boundary["message"],
628
- active_track: active_track(state)
629
- )
630
- elsif failure_branch
631
- base.merge(
632
- action: "route_failure_branch",
633
- reason: "failure_branch_triggered",
634
- failure_branch: failure_branch
635
- )
636
- elsif state["status"] == "waiting_checkpoint"
562
+ if state["status"] == "waiting_checkpoint"
637
563
  missing_outputs = current_outputs
638
564
  .select { |output| output["required"] && !output["exists"] }
639
565
  .map { |output| output["resolved_path"] }
@@ -647,8 +573,9 @@ result =
647
573
  end
648
574
  missing_outputs.concat(checkpoint_meta_errors.map { |entry| entry["path"] }.compact)
649
575
  base.merge(
650
- action: missing_outputs.empty? ? "await_user_confirmation" : "blocked",
651
- reason: missing_outputs.empty? ? nil : "missing_checkpoint_outputs",
576
+ action: "await_user_confirmation",
577
+ reason: nil,
578
+ warnings: Array(base[:warnings]) + (missing_outputs.empty? ? [] : [{ code: "missing_checkpoint_outputs", paths: missing_outputs, soft_gate: true }]),
652
579
  missing_outputs: missing_outputs,
653
580
  checkpoint_meta_errors: checkpoint_meta_errors,
654
581
  resume_behavior: checkpoint && checkpoint["resume_behavior"],