@guanlin4924/devflow-pi 0.1.39 → 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 (53) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
  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 +334 -30
  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 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  45. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  46. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
  47. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
  48. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  49. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  50. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  52. package/src/constants.ts +1 -1
  53. package/src/runtime.ts +61 -24
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Recover a legacy parallel window whose workers produced valid artifacts but
5
+ # whose terminal lifecycle events were lost. This is deliberately a Core-only
6
+ # writer: it validates every candidate branch before changing any branch, uses
7
+ # ParallelRun's locked run-revision CAS for branch transitions, and delegates
8
+ # the workflow/STATE mutation to advance_state.rb's existing join action.
9
+
10
+ require "date"
11
+ require "digest"
12
+ require "json"
13
+ require "open3"
14
+ require "optparse"
15
+ require "time"
16
+ require "yaml"
17
+ require_relative "control_io"
18
+ require_relative "parallel_run"
19
+
20
+ options = {
21
+ feature: nil,
22
+ expected_state_revision: nil,
23
+ expected_run_revision: nil,
24
+ reason: "legacy_parallel_lifecycle_event_lost",
25
+ write: false,
26
+ json: false,
27
+ }
28
+
29
+ OptionParser.new do |opts|
30
+ opts.banner = "Usage: parallel_recover.rb [options]"
31
+ opts.on("--feature NAME", "Active feature whose parallel window is recovered") { |value| options[:feature] = value }
32
+ opts.on("--expected-state-revision REV", Integer, "CAS revision from STATE.md") { |value| options[:expected_state_revision] = value }
33
+ opts.on("--expected-run-revision REV", Integer, "CAS revision from branches.yaml") { |value| options[:expected_run_revision] = value }
34
+ opts.on("--reason TEXT", "Audit reason for the recovery") { |value| options[:reason] = value }
35
+ opts.on("--write", "Apply validated branch transitions and controlled join") { options[:write] = true }
36
+ opts.on("--json", "Print JSON output") { options[:json] = true }
37
+ end.parse!
38
+
39
+ def front_matter(markdown)
40
+ markdown.match(/\A---\n(.*?)\n---\n/m)&.[](1)
41
+ end
42
+
43
+ def load_yaml_mapping(path, label)
44
+ value = YAML.safe_load(File.read(path), permitted_classes: [Date, Time], aliases: false)
45
+ raise ArgumentError, "#{label} must be a mapping" unless value.is_a?(Hash)
46
+
47
+ value
48
+ end
49
+
50
+ def interpolate_path(path, state)
51
+ path.to_s
52
+ .gsub("{feature}", state.fetch("current_feature", "").to_s)
53
+ .gsub("{phase}", state.fetch("current_phase", "").to_s)
54
+ end
55
+
56
+ def required_output_specs(step)
57
+ outputs = Array(step["outputs"]).select { |item| item.is_a?(Hash) && item["required"] == true }
58
+ outputs << step["meta_output"] if step["meta_output"].is_a?(Hash)
59
+ outputs
60
+ end
61
+
62
+ def actual_content_issues(path, checks)
63
+ issues = []
64
+ min_bytes = checks["min_bytes"]
65
+ required_header = checks["required_header"]
66
+ issues << "below_min_bytes(#{File.size(path)} < #{min_bytes})" if min_bytes && File.size(path) < Integer(min_bytes)
67
+ if required_header
68
+ head = File.read(path, [required_header.to_s.length + 50, 200].min)
69
+ issues << "missing_required_header(#{required_header.inspect})" unless head.include?(required_header.to_s)
70
+ end
71
+ issues
72
+ end
73
+
74
+ def validate_registered_outputs(root, workflow, state, step, registry_by_path)
75
+ checks = workflow.dig("artifact_registry", "content_checks") || {}
76
+ required_output_specs(step).map do |spec|
77
+ relative = interpolate_path(spec["path"], state)
78
+ errors = []
79
+ errors << "required output path missing from workflow" if relative.empty?
80
+ registry = registry_by_path[relative]
81
+ errors << "artifact registry entry missing" unless registry
82
+
83
+ absolute = nil
84
+ begin
85
+ absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact) unless relative.empty?
86
+ rescue DevflowControlIO::InvalidControlPath => error
87
+ errors << error.message
88
+ end
89
+ errors << "artifact file missing" unless absolute && File.file?(absolute)
90
+
91
+ if registry
92
+ errors << "registry workflow_step mismatch" unless registry["workflow_step"].to_s == step["id"].to_s
93
+ errors << "registry type mismatch" if spec["type"] && registry["type"].to_s != spec["type"].to_s
94
+ errors << "registry exists metadata is not true" unless registry["exists"] == true
95
+ errors << "registry content_valid metadata is not true" unless registry["content_valid"] == true
96
+ errors << "registry reports content issues" unless Array(registry["content_issues"]).empty?
97
+ end
98
+ errors.concat(actual_content_issues(absolute, checks)) if absolute && File.file?(absolute)
99
+
100
+ {
101
+ "path" => relative,
102
+ "workflow_step" => step["id"],
103
+ "registry_status" => registry && registry["status"],
104
+ "exists" => absolute && File.file?(absolute),
105
+ "sha256" => absolute && File.file?(absolute) ? Digest::SHA256.file(absolute).hexdigest : nil,
106
+ "valid" => errors.empty?,
107
+ "errors" => errors,
108
+ }.compact
109
+ end
110
+ end
111
+
112
+ def generate_machine_evidence(root, feature, state_revision, branch, expected_paths)
113
+ script = File.join(__dir__, "machine_evidence.rb")
114
+ stdout, stderr, status = Open3.capture3(
115
+ "ruby", script,
116
+ "--feature", feature,
117
+ "--step", branch.fetch("workflow_step").to_s,
118
+ "--parallel-branch-id", branch.fetch("branch_id").to_s,
119
+ "--parallel-attempt", Integer(branch.fetch("attempt", 0)).to_s,
120
+ "--parallel-branch-revision", Integer(branch.fetch("branch_revision", 0)).to_s,
121
+ "--lock-held", "--write", "--json",
122
+ chdir: root,
123
+ )
124
+ payload = JSON.parse(stdout)
125
+ raise ArgumentError, (stderr.strip.empty? ? payload["message"] || "machine evidence failed" : stderr.strip) unless status.success? && payload.is_a?(Hash) && payload["ok"] == true
126
+
127
+ evidence_path = payload.fetch("path")
128
+ absolute, = DevflowControlIO.resolve_control_path(root, evidence_path, :artifact)
129
+ pack = JSON.parse(File.read(absolute))
130
+ binding = pack.dig("evidence", "parallel_binding")
131
+ expected_binding = {
132
+ "branch_id" => branch.fetch("branch_id").to_s,
133
+ "attempt" => Integer(branch.fetch("attempt", 0)),
134
+ "branch_revision" => Integer(branch.fetch("branch_revision", 0)),
135
+ "state_revision" => state_revision,
136
+ }
137
+ errors = []
138
+ errors << "machine evidence pack is incomplete" unless payload["complete"] == true && pack["complete"] == true
139
+ errors << "machine evidence origin is not Core" unless pack["origin"] == "core_machine"
140
+ errors << "machine evidence feature mismatch" unless pack["feature"].to_s == feature.to_s
141
+ errors << "machine evidence step mismatch" unless pack["step"].to_s == branch["workflow_step"].to_s
142
+ errors << "machine evidence binding mismatch" unless binding.is_a?(Hash) && expected_binding.all? { |key, value| binding[key].to_s == value.to_s }
143
+ packed_paths = Array(pack["files"]).select { |item| item.is_a?(Hash) && item["required"] == true }.map { |item| item["path"].to_s }.sort
144
+ errors << "machine evidence required outputs mismatch" unless packed_paths == expected_paths.sort
145
+ errors << "machine evidence contains a missing required file" unless Array(pack["files"]).all? { |item| item.is_a?(Hash) && item["exists"] == true && item["sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) }
146
+ raise ArgumentError, errors.join("; ") unless errors.empty?
147
+
148
+ output_fingerprint = Digest::SHA256.hexdigest(
149
+ Array(pack["files"]).map { |item| [item["path"].to_s, item["sha256"].to_s] }.sort.to_json,
150
+ )
151
+ {
152
+ "evidence_path" => evidence_path,
153
+ "evidence_sha256" => Digest::SHA256.file(absolute).hexdigest,
154
+ "output_fingerprint" => output_fingerprint,
155
+ "checkers" => Array(pack["checkers"]).map { |item| item.slice("id", "passed", "exit_code") },
156
+ }
157
+ rescue JSON::ParserError => error
158
+ raise ArgumentError, "machine evidence is not valid JSON: #{error.message}"
159
+ end
160
+
161
+ def audit_path(root, feature)
162
+ DevflowControlIO.resolve_control_path(root, ".planning/evidence/#{feature}/parallel-recovery.jsonl", :artifact).first
163
+ end
164
+
165
+ def append_audit(root, feature, payload, lock: nil)
166
+ owned_lock = lock.nil?
167
+ lock ||= DevflowControlIO.acquire_project_lock(root)
168
+ DevflowControlIO.append_audit(audit_path(root, feature), payload.merge("timestamp" => Time.now.utc.iso8601))
169
+ ensure
170
+ DevflowControlIO.release_lock(lock) if owned_lock
171
+ end
172
+
173
+ def recovery_id(run, expected_state_revision, expected_run_revision)
174
+ material = [run["run_id"], expected_state_revision, expected_run_revision].join(":")
175
+ "REC-#{Digest::SHA256.hexdigest(material)[0, 20]}"
176
+ end
177
+
178
+ def prior_validation(root, feature, recovery, run)
179
+ path = audit_path(root, feature)
180
+ return nil unless File.file?(path)
181
+
182
+ events = File.foreach(path).each_with_object([]) do |line, parsed|
183
+ next if line.strip.empty?
184
+ begin
185
+ parsed << JSON.parse(line)
186
+ rescue JSON::ParserError
187
+ # A malformed historical line cannot authorize a resumed recovery.
188
+ end
189
+ end
190
+ validated = events.reverse.find do |event|
191
+ event["origin"] == "core.parallel_recovery/v1" && event["event"] == "validated" &&
192
+ event["recovery_id"] == recovery && event["run_id"].to_s == run["run_id"].to_s
193
+ end
194
+ return nil unless validated
195
+
196
+ logged = Array(validated["branches"]).to_h { |branch| [branch["branch_id"].to_s, branch] }
197
+ Array(run["branches"]).each do |branch|
198
+ next unless branch["status"] == "succeeded" && logged.key?(branch["branch_id"].to_s)
199
+
200
+ machine = logged.fetch(branch["branch_id"].to_s)["machine_evidence"] || {}
201
+ return nil unless branch["evidence_path"].to_s == machine["evidence_path"].to_s
202
+ return nil unless branch["output_fingerprint"].to_s == machine["output_fingerprint"].to_s
203
+ end
204
+ validated
205
+ end
206
+
207
+ def parse_json_result(stdout, stderr, status)
208
+ payload = JSON.parse(stdout)
209
+ return payload if payload.is_a?(Hash)
210
+
211
+ { "ok" => false, "reason" => "join_returned_invalid_payload", "message" => stderr.strip, "exit_status" => status.exitstatus }
212
+ rescue JSON::ParserError => error
213
+ { "ok" => false, "reason" => "join_returned_invalid_json", "message" => error.message, "stderr" => stderr.strip, "exit_status" => status.exitstatus }
214
+ end
215
+
216
+ root = Dir.pwd
217
+ feature = options[:feature].to_s
218
+ result = nil
219
+ exit_status = 0
220
+
221
+ begin
222
+ raise ArgumentError, "--feature is required" if feature.empty?
223
+ ParallelRun.safe_feature(feature)
224
+ raise ArgumentError, "--expected-state-revision is required" if options[:expected_state_revision].nil?
225
+ raise ArgumentError, "--expected-run-revision is required" if options[:expected_run_revision].nil?
226
+ raise ArgumentError, "--write is required" unless options[:write]
227
+ raise ArgumentError, "--reason must not be empty" if options[:reason].to_s.strip.empty?
228
+
229
+ state_revision = nil
230
+ current_run_revision = nil
231
+ recovery = nil
232
+ recovered_branches = []
233
+ validation_records = []
234
+ project_lock = DevflowControlIO.acquire_project_lock(root)
235
+ begin
236
+ state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
237
+ state_yaml = front_matter(File.read(state_path))
238
+ raise ArgumentError, "STATE.md has no YAML front matter" unless state_yaml
239
+ state = YAML.safe_load(state_yaml, permitted_classes: [Date, Time], aliases: false)
240
+ raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
241
+ raise ArgumentError, "feature is not active: requested=#{feature}, current=#{state['current_feature']}" unless state["current_feature"].to_s == feature
242
+
243
+ run = ParallelRun.load(root, feature)
244
+ raise ArgumentError, "parallel run not found" unless run
245
+ recovery = recovery_id(run, options[:expected_state_revision], options[:expected_run_revision])
246
+ branches = Array(run["branches"])
247
+ already_joined = state["parallel_run_ref"].nil? && run["status"] == "completed" && branches.any? && branches.all? { |branch| branch["status"] == "succeeded" }
248
+ if already_joined
249
+ append_audit(root, feature, {
250
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "idempotent_noop",
251
+ "recovery_id" => recovery, "reason" => options[:reason], "run_id" => run["run_id"],
252
+ "state_revision" => state["revision"], "run_revision" => run["run_revision"],
253
+ }, lock: project_lock)
254
+ result = {
255
+ "ok" => true, "written" => false, "idempotent" => true, "recovery_id" => recovery,
256
+ "actions" => ["parallel_recovery_already_joined"], "state_revision" => state["revision"],
257
+ "run_revision" => run["run_revision"], "audit_path" => audit_path(root, feature).sub("#{root}/", ""),
258
+ }
259
+ else
260
+ state_revision = Integer(state.fetch("revision", 0))
261
+ current_run_revision = Integer(run.fetch("run_revision", 0))
262
+ raise ArgumentError, "stale STATE revision: expected=#{options[:expected_state_revision]}, actual=#{state_revision}" unless state_revision == options[:expected_state_revision]
263
+ raise ArgumentError, "STATE parallel_run_ref does not match run" unless state["parallel_run_ref"].to_s == run["run_id"].to_s
264
+ raise ArgumentError, "parallel run is bound to stale STATE revision" unless Integer(run.fetch("bound_state_revision")) == state_revision
265
+ raise ArgumentError, "parallel run has no branches" if branches.empty?
266
+ ineligible = branches.select { |branch| %w[failed aborted].include?(branch["status"].to_s) }
267
+ raise ArgumentError, "failed/aborted branches require explicit retry: #{ineligible.map { |branch| branch['branch_id'] }.join(', ')}" unless ineligible.empty?
268
+ previous_validation = prior_validation(root, feature, recovery, run)
269
+ unless current_run_revision == options[:expected_run_revision] || previous_validation
270
+ raise ArgumentError, "stale parallel run revision: expected=#{options[:expected_run_revision]}, actual=#{current_run_revision}"
271
+ end
272
+
273
+ workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
274
+ workflow = load_yaml_mapping(workflow_path, "workflow")
275
+ registry_source = workflow.dig("artifact_registry", "source").to_s
276
+ raise ArgumentError, "workflow artifact_registry.source is required for recovery" if registry_source.empty?
277
+ registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
278
+ registry = load_yaml_mapping(registry_path, "artifact registry")
279
+ raise ArgumentError, "artifact registry revision mismatch" unless Integer(registry.fetch("state_revision")) == state_revision
280
+ raise ArgumentError, "artifact registry feature mismatch" unless registry.dig("registry_scope", "feature").to_s == feature
281
+ registry_by_path = Array(registry["artifacts"]).each_with_object({}) do |artifact, index|
282
+ raise ArgumentError, "artifact registry entry must be a mapping" unless artifact.is_a?(Hash)
283
+ path = artifact["path"].to_s
284
+ raise ArgumentError, "artifact registry contains duplicate path: #{path}" if index.key?(path)
285
+ index[path] = artifact
286
+ end
287
+ steps = Array(workflow["steps"]).each_with_object({}) { |step, index| index[step["id"].to_s] = step if step.is_a?(Hash) }
288
+
289
+ candidates = branches.select { |branch| %w[pending running].include?(branch["status"].to_s) }
290
+ candidates.each do |branch|
291
+ step = steps[branch["workflow_step"].to_s]
292
+ raise ArgumentError, "parallel branch workflow step is unknown: #{branch['workflow_step']}" unless step
293
+ artifacts = validate_registered_outputs(root, workflow, state, step, registry_by_path)
294
+ invalid = artifacts.reject { |artifact| artifact["valid"] }
295
+ raise ArgumentError, "branch #{branch['branch_id']} artifact validation failed: #{invalid.to_json}" unless invalid.empty?
296
+ machine = generate_machine_evidence(root, feature, state_revision, branch, artifacts.map { |artifact| artifact["path"] })
297
+ validation_records << {
298
+ "branch_id" => branch["branch_id"], "workflow_step" => branch["workflow_step"],
299
+ "attempt" => branch.fetch("attempt", 0), "branch_revision" => branch.fetch("branch_revision", 0),
300
+ "artifacts" => artifacts, "machine_evidence" => machine,
301
+ }
302
+ end
303
+
304
+ append_audit(root, feature, {
305
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => previous_validation ? "validated_resume" : "validated",
306
+ "recovery_id" => recovery, "reason" => options[:reason], "run_id" => run["run_id"],
307
+ "state_revision" => state_revision, "run_revision" => current_run_revision,
308
+ "branches" => validation_records,
309
+ }, lock: project_lock)
310
+
311
+ validation_records.each do |validated|
312
+ machine = validated.fetch("machine_evidence")
313
+ updated = ParallelRun.update_branch(
314
+ root: root, feature: feature, branch_id: validated.fetch("branch_id"),
315
+ expected_revision: current_run_revision, status: "succeeded",
316
+ evidence_path: machine.fetch("evidence_path"), output_fingerprint: machine.fetch("output_fingerprint"),
317
+ lock: project_lock,
318
+ )
319
+ current_run_revision = Integer(updated.fetch("run").fetch("run_revision"))
320
+ recovered_branches << validated.fetch("branch_id")
321
+ end
322
+ final_run = ParallelRun.load(root, feature)
323
+ unless Array(final_run["branches"]).all? { |branch| branch["status"] == "succeeded" }
324
+ raise ArgumentError, "recovery did not produce an all-succeeded run"
325
+ end
326
+ append_audit(root, feature, {
327
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "branches_recovered",
328
+ "recovery_id" => recovery, "reason" => options[:reason], "run_id" => final_run["run_id"],
329
+ "state_revision" => state_revision, "run_revision" => final_run["run_revision"],
330
+ "recovered_branch_ids" => recovered_branches,
331
+ }, lock: project_lock)
332
+ end
333
+ ensure
334
+ DevflowControlIO.release_lock(project_lock)
335
+ end
336
+
337
+ unless result
338
+ advance = File.join(__dir__, "advance_state.rb")
339
+ stdout, stderr, status = Open3.capture3(
340
+ "ruby", advance, "--write", "--parallel-action", "join",
341
+ "--expected-revision", state_revision.to_s, "--json", chdir: root,
342
+ )
343
+ joined = parse_json_result(stdout, stderr, status)
344
+ if status.success? && joined["ok"] == true
345
+ joined_run = ParallelRun.load(root, feature)
346
+ append_audit(root, feature, {
347
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "joined",
348
+ "recovery_id" => recovery, "reason" => options[:reason], "run_id" => joined_run && joined_run["run_id"],
349
+ "state_revision_before" => state_revision, "state_revision_after" => joined.dig("state", "revision"),
350
+ "run_revision" => joined_run && joined_run["run_revision"], "recovered_branch_ids" => recovered_branches,
351
+ })
352
+ result = {
353
+ "ok" => true, "written" => true, "idempotent" => false, "recovery_id" => recovery,
354
+ "actions" => ["parallel_branches_recovered", "parallel_window_joined"],
355
+ "recovered_branch_ids" => recovered_branches, "state" => joined["state"], "run" => joined_run,
356
+ "audit_path" => audit_path(root, feature).sub("#{root}/", ""),
357
+ }
358
+ else
359
+ append_audit(root, feature, {
360
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "join_refused",
361
+ "recovery_id" => recovery, "reason" => options[:reason], "state_revision" => state_revision,
362
+ "run_revision" => current_run_revision, "join_result" => joined,
363
+ })
364
+ result = { "ok" => false, "reason" => "controlled_join_failed", "recovery_id" => recovery, "details" => joined }
365
+ exit_status = 1
366
+ end
367
+ end
368
+ rescue StandardError => error
369
+ exit_status = 1
370
+ begin
371
+ run = feature.empty? ? nil : ParallelRun.load(root, feature)
372
+ recovery ||= recovery_id(run, options[:expected_state_revision], options[:expected_run_revision]) if run
373
+ append_audit(root, feature, {
374
+ "schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "refused",
375
+ "recovery_id" => recovery, "reason" => options[:reason], "error_class" => error.class.name,
376
+ "message" => error.message, "expected_state_revision" => options[:expected_state_revision],
377
+ "expected_run_revision" => options[:expected_run_revision],
378
+ }) unless feature.empty?
379
+ rescue StandardError
380
+ # Preserve the original refusal when even the audit path is unavailable.
381
+ end
382
+ result = { "ok" => false, "reason" => "parallel_recovery_refused", "message" => error.message, "recovery_id" => recovery }
383
+ end
384
+
385
+ puts(options[:json] ? JSON.generate(result) : JSON.pretty_generate(result))
386
+ exit exit_status
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "digest"
6
+ require "fileutils"
7
+ require "json"
8
+ require "optparse"
9
+ require "securerandom"
10
+ require "time"
11
+ require "yaml"
12
+ require_relative "control_io"
13
+
14
+ # Durable scheduler state for one workflow parallel window. This file owns
15
+ # dispatch state only; artifacts/index.yaml owns artifact content state and
16
+ # STATE.md owns the workflow snapshot/current pointer.
17
+ module ParallelRun
18
+ SCHEMA_VERSION = 1
19
+ TERMINAL_BRANCH_STATUSES = %w[succeeded failed aborted].freeze
20
+
21
+ module_function
22
+
23
+ def safe_feature(feature)
24
+ value = feature.to_s
25
+ raise ArgumentError, "feature is required" if value.empty?
26
+ raise ArgumentError, "feature contains path separators" if value.include?("/") || value.include?("\\") || value == "." || value == ".."
27
+
28
+ value
29
+ end
30
+
31
+ def path(root, feature)
32
+ File.join(root, ".planning", "parallel", safe_feature(feature), "branches.yaml")
33
+ end
34
+
35
+ def load(root, feature)
36
+ target = path(root, feature)
37
+ return nil unless File.file?(target)
38
+
39
+ value = YAML.safe_load(File.read(target), permitted_classes: [Date, Time], aliases: false)
40
+ raise ArgumentError, "parallel run record must be a mapping" unless value.is_a?(Hash)
41
+ value
42
+ end
43
+
44
+ def write(root, feature, record)
45
+ raise ArgumentError, "parallel run record must be a mapping" unless record.is_a?(Hash)
46
+
47
+ DevflowControlIO.atomic_write(path(root, feature), record.to_yaml)
48
+ record
49
+ end
50
+
51
+ # STATE.md is the source of truth for the workflow revision. A parallel
52
+ # window remains valid across ordinary state writes, so its optimistic
53
+ # revision bindings must move forward atomically with STATE. Callers must
54
+ # hold the project lock when passing one; the helper acquires it otherwise.
55
+ def rebase_state_revision(root:, feature:, state_revision:, lock: nil)
56
+ owned_lock = lock.nil?
57
+ lock ||= DevflowControlIO.acquire_project_lock(root)
58
+ begin
59
+ record = load(root, feature)
60
+ return nil unless record
61
+
62
+ revision = Integer(state_revision)
63
+ record["bound_state_revision"] = revision
64
+ Array(record["branches"]).each { |branch| branch["expected_state_revision"] = revision }
65
+ record["updated_at"] = Time.now.utc.iso8601
66
+ write(root, feature, record)
67
+ record
68
+ ensure
69
+ DevflowControlIO.release_lock(lock) if owned_lock
70
+ end
71
+ end
72
+
73
+ def state_front_matter(markdown)
74
+ match = markdown.match(/\A---\n(.*?)\n---\n/m)
75
+ match && match[1]
76
+ end
77
+
78
+ def attach_state_ref(root, state_path, run_id, expected_revision)
79
+ absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
80
+ original = File.read(absolute)
81
+ front = state_front_matter(original)
82
+ raise ArgumentError, "STATE.md has no YAML front matter" unless front
83
+
84
+ state = YAML.safe_load(front, permitted_classes: [], aliases: false)
85
+ raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
86
+ actual = Integer(state.fetch("revision", 0))
87
+ raise ArgumentError, "STATE revision conflict" unless actual == Integer(expected_revision)
88
+ raise ArgumentError, "STATE already references a parallel run" if state["parallel_run_ref"]
89
+
90
+ state["parallel_run_ref"] = run_id
91
+ state["revision"] = actual + 1
92
+ body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
93
+ DevflowControlIO.atomic_write(absolute, body)
94
+ state
95
+ end
96
+
97
+ def prepare(root:, feature:, workflow_window:, state_revision:, branches:, task_ids: {}, state_path: ".planning/STATE.md")
98
+ lock = DevflowControlIO.acquire_project_lock(root)
99
+ begin
100
+ raise ArgumentError, "parallel run already exists" if load(root, feature)
101
+
102
+ run_id = "PR-#{SecureRandom.uuid}"
103
+ branch_records = Array(branches).map do |branch|
104
+ value = branch.is_a?(Hash) ? branch : { "branch_id" => branch }
105
+ workflow_step = value["workflow_step"].to_s
106
+ track = value["track"].to_s
107
+ raise ArgumentError, "workflow_step is required" if workflow_step.empty?
108
+ raise ArgumentError, "track is required" if track.empty?
109
+ branch_id = value["branch_id"].to_s
110
+ branch_id = "#{run_id}-#{track.sub(/\Atrack_/, "")}-#{workflow_step}" if branch_id.empty?
111
+
112
+ value.merge(
113
+ "branch_id" => branch_id,
114
+ "workflow_step" => workflow_step,
115
+ "track" => track,
116
+ "attempt" => 0,
117
+ "branch_revision" => 0,
118
+ "status" => "pending",
119
+ "task_id" => task_ids[branch_id],
120
+ "previous_branch_id" => nil,
121
+ "history" => [],
122
+ ).compact
123
+ end
124
+ record = {
125
+ "schema_version" => SCHEMA_VERSION,
126
+ "run_id" => run_id,
127
+ "workflow_window" => workflow_window,
128
+ "bound_state_revision" => Integer(state_revision),
129
+ "run_revision" => 0,
130
+ "status" => "pending",
131
+ "created_at" => Time.now.utc.iso8601,
132
+ "branches" => branch_records,
133
+ }
134
+ write(root, feature, record)
135
+ updated_state = attach_state_ref(root, state_path, run_id, state_revision)
136
+ record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
137
+ record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
138
+ write(root, feature, record)
139
+ { "ok" => true, "run" => record }
140
+ ensure
141
+ DevflowControlIO.release_lock(lock)
142
+ end
143
+ end
144
+
145
+ def update_branch(root:, feature:, branch_id:, expected_revision:, status:, task_id: nil, evidence_path: nil, output_fingerprint: nil, error: nil, lock: nil)
146
+ owned_lock = lock.nil?
147
+ lock ||= DevflowControlIO.acquire_project_lock(root)
148
+ begin
149
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
150
+ actual = Integer(record.fetch("run_revision", 0))
151
+ raise ArgumentError, "parallel run revision conflict" unless actual == Integer(expected_revision)
152
+
153
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
154
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
155
+ raise ArgumentError, "invalid branch status: #{status}" unless (TERMINAL_BRANCH_STATUSES + %w[pending running]).include?(status)
156
+ raise ArgumentError, "terminal branch must retry before update" if TERMINAL_BRANCH_STATUSES.include?(branch["status"])
157
+
158
+ branch["status"] = status
159
+ branch["branch_revision"] = Integer(branch.fetch("branch_revision", 0)) + 1
160
+ branch["task_id"] = task_id if task_id
161
+ branch["evidence_path"] = evidence_path if evidence_path
162
+ branch["output_fingerprint"] = output_fingerprint if output_fingerprint
163
+ branch["error"] = error if error
164
+ record["run_revision"] = actual + 1
165
+ record["status"] = "failed" if status == "failed"
166
+ record["status"] = "running" if status == "running" && record["status"] == "pending"
167
+ record["status"] = "completed" if Array(record["branches"]).all? { |item| item["status"] == "succeeded" }
168
+ record["updated_at"] = Time.now.utc.iso8601
169
+ write(root, feature, record)
170
+ { "ok" => true, "run" => record }
171
+ ensure
172
+ DevflowControlIO.release_lock(lock) if owned_lock
173
+ end
174
+ end
175
+
176
+ def retry_branch(root:, feature:, branch_id:, expected_revision:, lock: nil)
177
+ owned_lock = lock.nil?
178
+ lock ||= DevflowControlIO.acquire_project_lock(root)
179
+ begin
180
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
181
+ raise ArgumentError, "parallel run revision conflict" unless Integer(record.fetch("run_revision", 0)) == Integer(expected_revision)
182
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
183
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
184
+ raise ArgumentError, "only failed/aborted branch can retry" unless %w[failed aborted].include?(branch["status"])
185
+
186
+ previous_id = branch["branch_id"]
187
+ branch["history"] ||= []
188
+ branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
189
+ branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
190
+ branch["branch_revision"] = 0
191
+ branch["previous_branch_id"] = previous_id
192
+ branch["status"] = "pending"
193
+ branch.delete("error")
194
+ record["status"] = "running"
195
+ record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
196
+ record["updated_at"] = Time.now.utc.iso8601
197
+ write(root, feature, record)
198
+ { "ok" => true, "run" => record }
199
+ ensure
200
+ DevflowControlIO.release_lock(lock) if owned_lock
201
+ end
202
+ end
203
+ end
204
+
205
+ if $PROGRAM_NAME == __FILE__
206
+ begin
207
+ options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
208
+ OptionParser.new do |opts|
209
+ opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
210
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
211
+ opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
212
+ opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
213
+ opts.on("--branch-id ID") { |value| options[:branch_id] = value }
214
+ opts.on("--status STATUS") { |value| options[:status] = value }
215
+ opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
216
+ opts.on("--state PATH") { |value| options[:state_path] = value }
217
+ opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
218
+ workflow_step, track = value.split(":", 2)
219
+ raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
220
+ options[:branches] << { "workflow_step" => workflow_step, "track" => track }
221
+ end
222
+ opts.on("--json") { options[:json] = true }
223
+ end.parse!
224
+
225
+ raise ArgumentError, "--feature is required" unless options[:feature]
226
+ result = case options[:action]
227
+ when "inspect"
228
+ record = ParallelRun.load(Dir.pwd, options[:feature])
229
+ record ? { "ok" => true, "run" => record } : { "ok" => false, "reason" => "parallel_run_not_found" }
230
+ when "prepare"
231
+ ParallelRun.prepare(root: Dir.pwd, feature: options[:feature], workflow_window: options[:workflow_window], state_revision: options[:state_revision], branches: options[:branches], state_path: options[:state_path])
232
+ when "update"
233
+ ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
234
+ when "retry"
235
+ ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
236
+ when "rebase"
237
+ raise ArgumentError, "--state-revision is required" if options[:state_revision].nil?
238
+ ParallelRun.rebase_state_revision(root: Dir.pwd, feature: options[:feature], state_revision: options[:state_revision])
239
+ .then { |run| run ? { "ok" => true, "run" => run } : { "ok" => false, "reason" => "parallel_run_not_found" } }
240
+ else
241
+ raise ArgumentError, "--action must be inspect, prepare, update, retry, or rebase"
242
+ end
243
+ puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
244
+ rescue StandardError => e
245
+ warn e.message
246
+ exit 1
247
+ end
248
+ end