@guanlin4924/devflow-pi 0.1.105 → 0.1.107

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 (66) hide show
  1. package/README.md +10 -8
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +287 -394
  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-dynamic/SKILL.md +41 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +37 -50
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +46 -191
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/artifact_specs.rb +12 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dynamic_plan.rb +504 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +38 -1
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_policy.rb +11 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +12 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +4 -9
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +19 -86
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +24 -13
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +4 -4
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +7 -1
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +17 -39
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +39 -3
  26. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +2 -3
  27. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +0 -1
  28. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +13 -5
  29. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +1 -1
  30. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +2 -0
  31. package/plugins/devflow-design/skills/workflow-api-call-guide/SKILL.md +2 -0
  32. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +3 -1
  33. package/plugins/devflow-design/skills/workflow-api-supplement/SKILL.md +2 -0
  34. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +2 -0
  35. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +6 -4
  36. package/plugins/devflow-design/skills/workflow-design-rule-pack/SKILL.md +2 -0
  37. package/plugins/devflow-design/skills/workflow-feature-breakdown/SKILL.md +2 -0
  38. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -4
  39. package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +3 -1
  40. package/plugins/devflow-design/skills/workflow-ui-spec/SKILL.md +4 -2
  41. package/plugins/devflow-implementation/agents/implementation-worker.md +2 -8
  42. package/plugins/devflow-implementation/skills/workflow-bug-fix/SKILL.md +5 -0
  43. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +3 -1
  44. package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +6 -1
  45. package/plugins/devflow-intel/skills/code-index-query/SKILL.md +2 -0
  46. package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +2 -0
  47. package/plugins/devflow-intel/skills/workflow-context-sync/SKILL.md +2 -0
  48. package/plugins/devflow-intel/skills/workflow-update-navigation/SKILL.md +3 -1
  49. package/plugins/devflow-quality/agents/code-reviewer.md +2 -0
  50. package/plugins/devflow-quality/skills/code-review/SKILL.md +7 -2
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -1
  52. package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +2 -0
  53. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  54. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +2 -0
  55. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +2 -0
  56. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +7 -5
  57. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +11 -6
  58. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +2 -0
  59. package/plugins/devflow-requirements/skills/workflow-demand-clarify/SKILL.md +2 -0
  60. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +2 -0
  61. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +6 -2
  62. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +5 -0
  63. package/plugins/devflow-requirements/skills/workflow-wayfinder/SKILL.md +2 -0
  64. package/src/constants.ts +1 -1
  65. package/src/dynamic-workflow.ts +31 -0
  66. package/templates/omp/AGENTS.md +8 -3
@@ -0,0 +1,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "digest"
6
+ require "securerandom"
7
+ require "open3"
8
+ require "timeout"
9
+ require_relative "control_io"
10
+
11
+ # STATE is the only mutable authority. Documents own intent, never verdicts.
12
+ module DynamicPlan
13
+ SKILLS = {
14
+ "discover" => "workflow-requirement-triage",
15
+ "implement" => "workflow-execute-phase",
16
+ "repair" => "workflow-bug-fix",
17
+ "verify" => "workflow-run-acceptance-tests",
18
+ "review" => "code-review"
19
+ }.freeze
20
+ module_function
21
+
22
+ def digest(value)
23
+ Digest::SHA256.hexdigest(JSON.generate(value))
24
+ end
25
+
26
+ def normalize(text)
27
+ text.to_s.split.join(" ")
28
+ end
29
+
30
+ def path(root, relative)
31
+ raise ArgumentError, "project-relative path required" unless relative.is_a?(String) && !relative.empty? && !relative.start_with?("/") && !relative.split(/[\\\/]/).include?("..")
32
+ absolute = File.expand_path(relative, root)
33
+ cursor = absolute
34
+ cursor = File.dirname(cursor) until File.exist?(cursor) || File.symlink?(cursor)
35
+ real = File.realpath(cursor)
36
+ raise ArgumentError, "path escapes project" unless real == File.realpath(root) || real.start_with?(File.realpath(root) + "/")
37
+ absolute
38
+ end
39
+
40
+ def clauses(root, plan)
41
+ text = File.read(path(root, plan.fetch("document")))
42
+ result = {}
43
+ text.scan(/^### (R-[A-Za-z0-9_-]+)[::]([^\n]*\n(?:(?!^### |^## ).*\n?)*)/).each do |id, body|
44
+ raise ArgumentError, "duplicate requirement #{id}" if result.key?(id)
45
+ result[id] = normalize(body)
46
+ end
47
+ result
48
+ end
49
+
50
+ def file_pin(root, relative)
51
+ absolute = path(root, relative)
52
+ return "missing" unless File.exist?(absolute)
53
+ return Digest::SHA256.file(absolute).hexdigest if File.file?(absolute)
54
+ entries = Dir.glob(File.join(absolute, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |entry|
55
+ next if entry.sub(absolute + "/", "").split("/").any? { |part| %w[.git node_modules .devflow __pycache__].include?(part) }
56
+ next unless File.file?(entry)
57
+ safe = path(root, entry.sub(File.expand_path(root) + "/", ""))
58
+ [entry.sub(absolute + "/", ""), Digest::SHA256.file(safe).hexdigest]
59
+ end
60
+ digest(entries)
61
+ end
62
+
63
+ def pins(root, plan, task)
64
+ requirements = clauses(root, plan)
65
+ {
66
+ "requirements" => task.fetch("requirements").to_h { |id| [id, requirements[id]] },
67
+ "inputs" => task.fetch("inputs").to_h { |file| [file, file_pin(root, file)] },
68
+ "spec" => digest(task.slice("kind", "requirements", "inputs", "outputs", "checks"))
69
+ }
70
+ end
71
+
72
+ def validate!(root, plan)
73
+ tasks = plan.fetch("tasks")
74
+ raise ArgumentError, "tasks must be an array" unless tasks.is_a?(Array)
75
+ ids = tasks.map { |task| task.fetch("id") }
76
+ raise ArgumentError, "duplicate task ids" unless ids.uniq == ids
77
+ tasks.each do |task|
78
+ raise ArgumentError, "invalid task id" unless task["id"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}\z/)
79
+ raise ArgumentError, "unknown capability" unless SKILLS.key?(task["kind"])
80
+ raise ArgumentError, "task title required" if task["title"].to_s.strip.empty?
81
+ %w[dependencies requirements inputs outputs checks].each { |key| raise ArgumentError, "#{key} must be an array" unless task[key].is_a?(Array) }
82
+ raise ArgumentError, "unknown dependency" unless (task["dependencies"] - ids).empty?
83
+ (task["inputs"] + task["outputs"]).each { |file| path(root, file) }
84
+ raise ArgumentError, "verification needs inputs and executable checks" if task["kind"] == "verify" && (task["checks"].empty? || task["inputs"].empty?)
85
+ raise ArgumentError, "review needs source inputs" if task["kind"] == "review" && task["inputs"].empty?
86
+ task["checks"].each do |check|
87
+ argv = check["argv"]
88
+ raise ArgumentError, "checks require nonempty argv arrays" unless argv.is_a?(Array) && argv.any? && argv.all? { |arg| arg.is_a?(String) && !arg.include?("\0") }
89
+ end
90
+ next unless task["status"] == "cancelled"
91
+ raise ArgumentError, "cancellation reason required" if task["reason"].to_s.strip.empty?
92
+ raise ArgumentError, "cancelled task still has consumers" if tasks.any? { |other| other["status"] != "cancelled" && other["dependencies"].include?(task["id"]) }
93
+ end
94
+ visited, visiting = [], []
95
+ visit = lambda do |id|
96
+ raise ArgumentError, "cyclic task dependencies" if visiting.include?(id)
97
+ return if visited.include?(id)
98
+ visiting << id
99
+ tasks.find { |task| task["id"] == id }["dependencies"].each { |dependency| visit.call(dependency) }
100
+ visiting.delete(id)
101
+ visited << id
102
+ end
103
+ ids.each { |id| visit.call(id) }
104
+ requirements = clauses(root, plan)
105
+ active = tasks.reject { |task| task["status"] == "cancelled" }
106
+ active.each do |task|
107
+ raise ArgumentError, "unknown requirement" unless (task["requirements"] - requirements.keys).empty?
108
+ end
109
+ unless tasks.empty? || plan["cancelled"]
110
+ raise ArgumentError, "requirement clauses missing from main document" if requirements.empty?
111
+ requirements.each_key do |id|
112
+ raise ArgumentError, "#{id} has no verification task" unless active.any? { |task| task["kind"] == "verify" && task["requirements"].include?(id) }
113
+ end
114
+ if active.any? { |task| %w[implement repair].include?(task["kind"]) }
115
+ raise ArgumentError, "code changes require independent review" unless active.any? { |task| task["kind"] == "review" }
116
+ end
117
+ ancestors = lambda do |task|
118
+ task["dependencies"].flat_map do |id|
119
+ [id, *ancestors.call(tasks.find { |other| other["id"] == id })]
120
+ end.uniq
121
+ end
122
+ active.select { |task| task["kind"] == "verify" }.each do |task|
123
+ producers = active.select { |other| other["kind"] == "implement" && (other["requirements"] & task["requirements"]).any? }
124
+ raise ArgumentError, "verification must depend on its implementation" unless (producers.map { |item| item["id"] } - ancestors.call(task)).empty?
125
+ end
126
+ if active.any? { |task| %w[implement repair].include?(task["kind"]) }
127
+ changed_requirements = active.select { |task| %w[implement repair].include?(task["kind"]) }.flat_map { |task| task["requirements"] }.uniq
128
+ changed_requirements.each do |id|
129
+ verifiers = active.select { |task| task["kind"] == "verify" && task["requirements"].include?(id) }
130
+ covered = active.any? do |task|
131
+ task["kind"] == "review" && task["requirements"].include?(id) && (verifiers.map { |item| item["id"] } - ancestors.call(task)).empty?
132
+ end
133
+ raise ArgumentError, "#{id} needs review after verification" unless covered
134
+ end
135
+ end
136
+ end
137
+ true
138
+ end
139
+
140
+ def refresh!(root, plan)
141
+ plan["tasks"].each do |task|
142
+ next unless task["status"] == "completed" && task["evidence"]
143
+ current_pins = pins(root, plan, task)
144
+ observed_pins = task["evidence"]["pins"]
145
+ unchanged = if %w[implement repair].include?(task["kind"])
146
+ observed_pins.slice("requirements", "spec") == current_pins.slice("requirements", "spec")
147
+ else
148
+ observed_pins == current_pins
149
+ end
150
+ next if unchanged
151
+ task["status"] = "pending"
152
+ task["verification"] = "stale"
153
+ end
154
+ loop do
155
+ changed = false
156
+ plan["tasks"].each do |task|
157
+ next unless task["status"] == "completed"
158
+ next if task["dependencies"].all? { |id| plan["tasks"].find { |other| other["id"] == id }&.dig("status") == "completed" }
159
+ task["status"], task["verification"], changed = "pending", "stale", true
160
+ end
161
+ break unless changed
162
+ end
163
+ end
164
+
165
+ def snapshot(root, state)
166
+ plan = state.fetch("plan")
167
+ refresh!(root, plan)
168
+ validation_error = begin
169
+ validate!(root, plan)
170
+ nil
171
+ rescue ArgumentError, KeyError => error
172
+ error.message
173
+ end
174
+ tasks = plan["tasks"]
175
+ ready = tasks.find do |task|
176
+ task["status"] == "pending" && task["dependencies"].all? { |id| tasks.find { |other| other["id"] == id }&.dig("status") == "completed" }
177
+ end
178
+ running = tasks.find { |task| %w[running checking].include?(task["status"]) }
179
+ complete = tasks.any? && tasks.all? { |task| %w[completed cancelled].include?(task["status"]) } && plan.fetch("decisions", []).none? { |item| item["status"] == "pending" }
180
+ action = if plan["cancelled"] then "workflow_cancelled"
181
+ elsif validation_error then "reconcile_plan"
182
+ elsif tasks.empty? then "plan_work"
183
+ elsif running then "resume_task"
184
+ elsif ready then "run_task"
185
+ elsif complete then "workflow_complete"
186
+ else "waiting" end
187
+ selected = running || ready
188
+ {
189
+ "ok" => true, "mode" => "dynamic", "action" => action, "revision" => state["revision"],
190
+ "current_flow" => "dynamic", "current_feature" => state["current_feature"], "current_phase" => state["current_phase"],
191
+ "status" => plan["cancelled"] ? "cancelled" : (complete && !validation_error ? "completed" : "in_progress"),
192
+ "document" => plan["document"], "plan_revision" => plan["revision"], "reason" => validation_error,
193
+ "progress_key" => digest(tasks.map { |task| task.slice("id", "status", "verification", "attempt").merge("results" => task.fetch("history", []).length) }),
194
+ "delivery" => plan["cancelled"] ? "cancelled" : (complete && !validation_error ? "ready" : "incomplete"),
195
+ "current_step" => selected && selected.merge("label" => selected["title"], "skill" => SKILLS[selected["kind"]]),
196
+ "workflow_steps" => tasks.map { |task| task.slice("id", "status", "verification", "reason").merge("label" => task["title"]) },
197
+ "tasks" => tasks, "decisions" => plan.fetch("decisions", [])
198
+ }
199
+ end
200
+
201
+ def read_state(root)
202
+ file, = DevflowControlIO.resolve_control_path(root, ".devflow/planning/STATE.md", :state)
203
+ return nil unless File.file?(file)
204
+ YAML.safe_load(File.read(file)[/\A---\n(.*?)\n---\n/m, 1], permitted_classes: [], aliases: false)
205
+ end
206
+
207
+ def save(root, state)
208
+ state["revision"] += 1
209
+ view = snapshot(root, state)
210
+ state["status"] = view["status"]
211
+ state["current_step"] = view.dig("current_step", "id")
212
+ state["completed_steps"] = state["plan"]["tasks"].select { |task| task["status"] == "completed" }.map { |task| task["id"] }
213
+ state["pending_steps"] = state["plan"]["tasks"].reject { |task| %w[completed cancelled].include?(task["status"]) }.map { |task| task["id"] }
214
+ file, = DevflowControlIO.resolve_control_path(root, ".devflow/planning/STATE.md", :state)
215
+ # JSON round-trip removes shared object identities; safe YAML readers must
216
+ # never need aliases to read a runtime state snapshot.
217
+ DevflowControlIO.atomic_write(file, JSON.parse(JSON.generate(state)).to_yaml + "---\n\n# Runtime state\n\nManaged by Core. Edit the requirement document to change intent.\n")
218
+ snapshot(root, state)
219
+ end
220
+
221
+ def transact(root, expected)
222
+ lock = DevflowControlIO.acquire_project_lock(root)
223
+ state = read_state(root)
224
+ raise ArgumentError, "dynamic plan not initialized" unless state && state["current_flow"] == "dynamic"
225
+ raise ArgumentError, "REVISION_CONFLICT: inspect again" unless expected.is_a?(Integer) && expected == state["revision"]
226
+ before = digest(state)
227
+ refresh!(root, state["plan"])
228
+ yield state
229
+ return snapshot(root, state) if digest(state) == before
230
+ save(root, state)
231
+ ensure
232
+ DevflowControlIO.release_lock(lock)
233
+ end
234
+
235
+ def run_check(root, check)
236
+ output, status, timed_out = +"", nil, false
237
+ seconds = [[Integer(check.fetch("timeout", 120)), 1].max, 600].min
238
+ Open3.popen2e(*check.fetch("argv"), chdir: root, pgroup: true) do |stdin, stream, wait|
239
+ stdin.close
240
+ reader = Thread.new do
241
+ loop do
242
+ chunk = stream.readpartial(4096)
243
+ output << chunk if output.bytesize < 32_768
244
+ end
245
+ rescue EOFError, IOError
246
+ nil
247
+ end
248
+ begin
249
+ Timeout.timeout(seconds) { status = wait.value.exitstatus }
250
+ rescue Timeout::Error
251
+ timed_out = true
252
+ Process.kill("KILL", -wait.pid) rescue nil
253
+ wait.value
254
+ ensure
255
+ # Drain the exited child's buffered output before closing the pipe.
256
+ # A descendant retaining the pipe cannot keep recovery waiting forever.
257
+ reader.join(1)
258
+ stream.close unless stream.closed?
259
+ reader.join
260
+ end
261
+ end
262
+ { "argv" => check["argv"], "exit_code" => status, "timeout" => timed_out, "output" => output.encode("UTF-8", invalid: :replace, undef: :replace), "passed" => status == 0 && !timed_out }
263
+ rescue SystemCallError => error
264
+ { "argv" => check["argv"], "passed" => false, "output" => error.message }
265
+ end
266
+
267
+ def failure!(plan, task, evidence)
268
+ task["status"], task["verification"] = "failed", "fail"
269
+ fingerprint = digest(evidence["checks"]&.map { |check| check.slice("argv", "exit_code", "output") } || evidence["review"])
270
+ previous = task.fetch("history", [])[0...-1].last
271
+ attempts = task.fetch("repair_attempts", 0)
272
+ if attempts >= 2 || (previous && previous["finding"] == fingerprint && previous["pins"] == evidence["pins"])
273
+ task["reason"] = "自动修复已停止:达到次数上限或没有进展;保留失败结果。"
274
+ return
275
+ end
276
+ evidence["finding"] = fingerprint
277
+ task["repair_attempts"] = attempts + 1
278
+ repair_id = "repair-#{digest(task['id'])[0, 12]}-#{attempts + 1}"
279
+ task["status"] = "pending"
280
+ task["dependencies"] << repair_id
281
+ plan["tasks"] << {
282
+ "id" => repair_id, "title" => "修复:#{task['title']}", "kind" => "repair", "requirements" => task["requirements"].dup,
283
+ "inputs" => task["inputs"].dup, "outputs" => [], "checks" => [], "dependencies" => [], "status" => "pending",
284
+ "verification" => "not_run", "finding" => evidence, "repairs" => task["id"], "history" => []
285
+ }
286
+ unless plan["tasks"].any? { |item| item["kind"] == "review" && item["status"] != "cancelled" && (task["requirements"] - item["requirements"]).empty? }
287
+ plan["tasks"] << {
288
+ "id" => "review-#{digest(task['id'])[0, 12]}-#{attempts + 1}", "title" => "独立审查修复结果", "kind" => "review", "requirements" => task["requirements"].dup,
289
+ "inputs" => task["inputs"].dup, "outputs" => [], "checks" => [], "dependencies" => plan["tasks"].select { |item| item["kind"] == "verify" && item["status"] != "cancelled" && (item["requirements"] & task["requirements"]).any? }.map { |item| item["id"] },
290
+ "status" => "pending", "verification" => "not_run", "history" => []
291
+ }
292
+ end
293
+ plan["revision"] += 1
294
+ end
295
+
296
+ def dispatch(root, request)
297
+ action = request.fetch("action")
298
+ if action == "inspect"
299
+ state = read_state(root)
300
+ return { "ok" => true, "mode" => "legacy" } unless state && state["current_flow"] == "dynamic"
301
+ return snapshot(root, state)
302
+ end
303
+ if action == "initialize"
304
+ lock = DevflowControlIO.acquire_project_lock(root)
305
+ begin
306
+ old = read_state(root)
307
+ raise ArgumentError, "active workflow exists; continue it or explicitly migrate" if old && !%w[completed cancelled].include?(old["status"])
308
+ feature = request.fetch("feature").to_s.strip
309
+ raise ArgumentError, "feature required" if feature.empty?
310
+ if old
311
+ archive = path(root, ".devflow/runs/plans/#{Digest::SHA256.hexdigest(old['current_phase'].to_s)[0, 16]}-#{SecureRandom.uuid}.json")
312
+ DevflowControlIO.atomic_write(archive, JSON.pretty_generate(old))
313
+ end
314
+ phase = request["phase"] || "feature-#{Digest::SHA256.hexdigest(feature)[0, 12]}-#{SecureRandom.hex(3)}"
315
+ raise ArgumentError, "invalid phase identifier" unless phase.is_a?(String) && phase.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/)
316
+ document = ".devflow/planning/features/#{phase}/需求.md"
317
+ absolute = path(root, document)
318
+ raise ArgumentError, "requirement document already exists; choose a new phase or resume" if File.exist?(absolute)
319
+ DevflowControlIO.atomic_write(absolute, "# #{feature}\n\n## 目标与范围\n\n#{request.fetch('description', feature)}\n\n## 验收要求\n\n在需求核实后,将可验证要求写成 `### R-1: 要求描述`,每条保留稳定编号。\n\n## 方案与决定\n\n执行进度由 Core 展示;此处不手工记录通过状态。\n")
320
+ state = { "state_schema_version" => 3, "revision" => old ? old["revision"] : -1, "current_flow" => "dynamic", "current_feature" => feature, "current_phase" => phase,
321
+ "plan" => { "version" => 1, "revision" => 0, "document" => document, "tasks" => [], "decisions" => [], "changes" => [] } }
322
+ return save(root, state)
323
+ ensure
324
+ DevflowControlIO.release_lock(lock)
325
+ end
326
+ end
327
+ if action == "verify"
328
+ claim = transact(root, request["expected_revision"]) do |state|
329
+ task = state["plan"]["tasks"].find { |item| item["id"] == request["task_id"] }
330
+ raise ArgumentError, "running verification attempt required" unless task && task["kind"] == "verify" && task["status"] == "running" && task["attempt"] == request["attempt"]
331
+ task["status"] = "checking"
332
+ task["runner_pid"] = Process.pid
333
+ task["check_pins"] = pins(root, state["plan"], task)
334
+ end
335
+ task = claim["tasks"].find { |item| item["id"] == request["task_id"] }
336
+ checks = task["checks"].map { |check| run_check(root, check) }
337
+ return transact(root, claim["revision"]) do |state|
338
+ current = state["plan"]["tasks"].find { |item| item["id"] == task["id"] }
339
+ raise ArgumentError, "verification inputs changed during execution" unless current["attempt"] == task["attempt"] && task["check_pins"] == pins(root, state["plan"], current)
340
+ evidence = { "attempt" => task["attempt"], "pins" => task["check_pins"], "checks" => checks, "at" => Time.now.utc.iso8601 }
341
+ current["history"] << evidence
342
+ current["evidence"] = evidence
343
+ if checks.all? { |check| check["passed"] }
344
+ current["status"], current["verification"] = "completed", "pass"
345
+ else
346
+ failure!(state["plan"], current, evidence)
347
+ end
348
+ end
349
+ end
350
+ transact(root, request["expected_revision"]) do |state|
351
+ plan = state["plan"]
352
+ raise ArgumentError, "workflow was cancelled; create a new requirement" if plan["cancelled"]
353
+ case action
354
+ when "cancel"
355
+ raise ArgumentError, "cancellation must come from user runtime action" unless request["runtime_cancel"] == true
356
+ plan["cancelled"] = true
357
+ plan["cancel_reason"] = request.fetch("reason")
358
+ plan["tasks"].each do |task|
359
+ next if task["status"] == "completed"
360
+ task["status"], task["reason"] = "cancelled", plan["cancel_reason"]
361
+ task.delete("attempt")
362
+ end
363
+ when "revise"
364
+ old_tasks = plan["tasks"].to_h { |task| [task["id"], task] }
365
+ new_requirements = clauses(root, plan)
366
+ scope_decision = plan["decisions"].find { |item| item["id"] == request["scope_decision_id"] }
367
+ permitted_removals = if scope_decision && scope_decision["status"] == "answered" && scope_decision["choice"] == scope_decision["approval_option"] && scope_decision["requirements_pin"] == digest(plan.fetch("requirements", {}))
368
+ scope_decision.fetch("remove_requirements", [])
369
+ else [] end
370
+ proposal = request.fetch("tasks")
371
+ raise ArgumentError, "keep removed tasks as cancelled with reasons" unless (old_tasks.keys - proposal.map { |task| task["id"] }).empty?
372
+ new_tasks = proposal.map do |item|
373
+ spec = item.slice("id", "title", "kind", "requirements", "inputs", "outputs", "checks", "dependencies")
374
+ %w[requirements inputs outputs checks dependencies].each { |key| spec[key] ||= [] }
375
+ old = old_tasks[item["id"]]
376
+ candidate = (old || { "status" => "pending", "verification" => "not_run", "history" => [] }).merge(spec)
377
+ changed = old && spec.any? { |key, value| key != "title" && old[key] != value }
378
+ if changed
379
+ candidate["status"], candidate["verification"] = "pending", "stale"
380
+ candidate.delete("attempt")
381
+ end
382
+ if item["cancelled"] == true
383
+ if old && old["verification"] == "fail"
384
+ withdrawn = (old["requirements"] - permitted_removals).empty? && (old["requirements"] & new_requirements.keys).empty?
385
+ raise ArgumentError, "cannot cancel a failing obligation without its explicit scope withdrawal" unless withdrawn
386
+ end
387
+ candidate["status"], candidate["reason"] = "cancelled", item["reason"]
388
+ candidate.delete("attempt")
389
+ end
390
+ candidate
391
+ end
392
+ plan["tasks"] = new_tasks
393
+ validate!(root, plan)
394
+ old_requirements = plan.fetch("requirements", {})
395
+ removed = old_requirements.keys - new_requirements.keys
396
+ raise ArgumentError, "removing acceptance requirements needs an explicit scope decision for those clauses" unless (removed - permitted_removals).empty?
397
+ plan["requirements"] = new_requirements
398
+ plan["revision"] += 1
399
+ plan["changes"] << { "revision" => plan["revision"], "reason" => request.fetch("reason"), "at" => Time.now.utc.iso8601 }
400
+ when "claim"
401
+ view = snapshot(root, state)
402
+ raise ArgumentError, "no runnable task" unless view["action"] == "run_task"
403
+ task = plan["tasks"].find { |item| item["id"] == view.dig("current_step", "id") }
404
+ task["status"], task["attempt"] = "running", SecureRandom.uuid
405
+ task["started_at"] = Time.now.utc.iso8601
406
+ task["dispatch_pins"] = pins(root, plan, task)
407
+ when "report", "review_result"
408
+ task = plan["tasks"].find { |item| item["id"] == request["task_id"] }
409
+ raise ArgumentError, "stale or missing task attempt" unless task && task["status"] == "running" && task["attempt"] == request["attempt"]
410
+ raise ArgumentError, "verification must run through Core" if task["kind"] == "verify"
411
+ if task["kind"] == "review"
412
+ raise ArgumentError, "independent reviewer result required" unless action == "review_result" && request["runtime_review"] == true
413
+ raise ArgumentError, "review inputs changed" unless task["dispatch_pins"] == pins(root, plan, task)
414
+ report = request.fetch("report")
415
+ raise ArgumentError, "invalid reviewer report" unless report["findings"].is_a?(Array) && report["summary"].is_a?(String)
416
+ evidence = { "pins" => pins(root, plan, task), "review" => report, "task_call_id" => request.fetch("task_call_id"), "attempt" => task["attempt"] }
417
+ task["history"] << evidence
418
+ task["evidence"] = evidence
419
+ if report["findings"].empty?
420
+ task["status"], task["verification"] = "completed", "pass"
421
+ else
422
+ failure!(plan, task, evidence)
423
+ end
424
+ else
425
+ raise ArgumentError, "requirements changed during execution" unless task["dispatch_pins"]["requirements"] == pins(root, plan, task)["requirements"]
426
+ raise ArgumentError, "result summary required" if request["summary"].to_s.strip.empty?
427
+ raise ArgumentError, "expected output missing" unless task["outputs"].all? { |file| File.exist?(path(root, file)) }
428
+ # Implementation is an activity result, never an acceptance pass.
429
+ evidence = { "pins" => pins(root, plan, task), "summary" => request["summary"], "attempt" => task["attempt"] }
430
+ task["evidence"] = evidence
431
+ task["history"] << evidence
432
+ task["status"], task["verification"] = "completed", "not_run"
433
+ end
434
+ when "recover"
435
+ task = plan["tasks"].find { |item| item["id"] == request["task_id"] }
436
+ raise ArgumentError, "only an interrupted attempt may recover" unless task && %w[running checking].include?(task["status"])
437
+ if task["status"] == "checking" && task["runner_pid"]
438
+ alive = begin
439
+ Process.kill(0, task["runner_pid"])
440
+ true
441
+ rescue Errno::ESRCH
442
+ false
443
+ end
444
+ raise ArgumentError, "verification process is still running" if alive
445
+ end
446
+ raise ArgumentError, "inspect actual effects and provide a recovery reason" if request["reason"].to_s.strip.empty?
447
+ task["history"] << { "attempt" => task["attempt"], "interrupted" => true, "reason" => request["reason"] }
448
+ task["status"] = "pending"
449
+ task.delete("attempt")
450
+ when "wait"
451
+ task = plan["tasks"].find { |item| item["id"] == request["task_id"] }
452
+ raise ArgumentError, "running attempt required" unless task && task["status"] == "running" && task["attempt"] == request["attempt"]
453
+ raise ArgumentError, "external dependency reason required" if request["reason"].to_s.strip.empty?
454
+ task["status"], task["reason"] = "waiting_external", request["reason"]
455
+ when "resume"
456
+ task = plan["tasks"].find { |item| item["id"] == request["task_id"] }
457
+ raise ArgumentError, "external waiting task required" unless task && task["status"] == "waiting_external"
458
+ raise ArgumentError, "evidence that external dependency is ready required" if request["reason"].to_s.strip.empty?
459
+ task["history"] << { "resumed" => true, "reason" => request["reason"] }
460
+ task["status"] = "pending"
461
+ task.delete("attempt")
462
+ when "decision"
463
+ options = request.fetch("options")
464
+ raise ArgumentError, "provide two or three concrete options" unless options.is_a?(Array) && (2..3).include?(options.length) && options.all? { |item| item.is_a?(String) && !item.strip.empty? }
465
+ question = request.fetch("question")
466
+ removals = request.fetch("remove_requirements", [])
467
+ raise ArgumentError, "scope removal must name existing requirement IDs" unless removals.is_a?(Array) && (removals - plan.fetch("requirements", {}).keys).empty?
468
+ raise ArgumentError, "scope removal needs an explicit approving option" if removals.any? && !options.include?(request["approval_option"])
469
+ binding = file_pin(root, plan["document"])
470
+ unless plan["decisions"].any? { |item| item["question"] == question && item["document_pin"] == binding }
471
+ plan["decisions"].select { |item| item["question"] == question && item["status"] == "pending" }.each { |item| item["status"] = "superseded" }
472
+ affected = request["task_ids"] || [snapshot(root, state).dig("current_step", "id")].compact
473
+ raise ArgumentError, "unknown affected decision task" unless (affected - plan["tasks"].map { |task| task["id"] }).empty?
474
+ blocked = plan["tasks"].select { |task| affected.include?(task["id"]) && !%w[completed cancelled].include?(task["status"]) }
475
+ prior = blocked.to_h { |task| [task["id"], task["status"] == "waiting_decision" ? "pending" : task["status"]] }
476
+ blocked.each { |task| task["status"] = "waiting_decision" }
477
+ plan["decisions"] << { "id" => SecureRandom.uuid, "question" => question, "options" => options, "status" => "pending", "document_pin" => binding, "requirements_pin" => digest(plan.fetch("requirements", {})), "blocked_tasks" => prior, "remove_requirements" => removals, "approval_option" => request["approval_option"] }
478
+ end
479
+ when "decide"
480
+ raise ArgumentError, "user response must come from runtime UI" unless request["runtime_decision"] == true
481
+ decision = plan["decisions"].find { |item| item["id"] == request["decision_id"] }
482
+ raise ArgumentError, "decision expired" unless decision && decision["status"] == "pending" && decision["document_pin"] == file_pin(root, plan["document"])
483
+ raise ArgumentError, "invalid option" unless decision["options"].include?(request["choice"])
484
+ decision["status"], decision["choice"] = "answered", request["choice"]
485
+ decision.fetch("blocked_tasks", {}).each do |id, prior|
486
+ task = plan["tasks"].find { |item| item["id"] == id }
487
+ still_blocked = plan["decisions"].any? { |item| item["status"] == "pending" && item.fetch("blocked_tasks", {}).key?(id) }
488
+ task["status"] = prior if task && task["status"] == "waiting_decision" && !still_blocked
489
+ end
490
+ else
491
+ raise ArgumentError, "unknown dynamic plan action"
492
+ end
493
+ end
494
+ end
495
+ end
496
+
497
+ if __FILE__ == $PROGRAM_NAME
498
+ begin
499
+ puts JSON.generate(DynamicPlan.dispatch(Dir.pwd, JSON.parse(STDIN.read)))
500
+ rescue StandardError => error
501
+ puts JSON.generate({ "ok" => false, "reason" => error.message })
502
+ exit 1
503
+ end
504
+ 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", {})),