@guanlin4924/devflow-pi 0.1.40 → 0.1.48

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 (50) hide show
  1. package/bin/apply-opencode-go-session-hotfix.mjs +61 -0
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  4. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  5. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  9. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  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 +13 -4
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  45. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  47. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  48. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  49. package/src/constants.ts +1 -1
  50. package/src/runtime.ts +53 -22
@@ -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
@@ -48,6 +48,28 @@ module ParallelRun
48
48
  record
49
49
  end
50
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
+
51
73
  def state_front_matter(markdown)
52
74
  match = markdown.match(/\A---\n(.*?)\n---\n/m)
53
75
  match && match[1]
@@ -184,7 +206,7 @@ if $PROGRAM_NAME == __FILE__
184
206
  begin
185
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 }
186
208
  OptionParser.new do |opts|
187
- opts.on("--action ACTION", %w[inspect prepare update retry]) { |value| options[:action] = value }
209
+ opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
188
210
  opts.on("--feature FEATURE") { |value| options[:feature] = value }
189
211
  opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
190
212
  opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
@@ -211,8 +233,12 @@ if $PROGRAM_NAME == __FILE__
211
233
  ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
212
234
  when "retry"
213
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" } }
214
240
  else
215
- raise ArgumentError, "--action must be inspect, prepare, update, or retry"
241
+ raise ArgumentError, "--action must be inspect, prepare, update, retry, or rebase"
216
242
  end
217
243
  puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
218
244
  rescue StandardError => e