@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.
- package/README.md +10 -8
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +287 -394
- package/plugins/devflow-core/runtime/hooks/context_sync_gate.py +3 -1
- package/plugins/devflow-core/runtime/hooks/hook_common.py +6 -5
- package/plugins/devflow-core/runtime/hooks/index_gate.py +19 -26
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +2 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +2 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +2 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +2 -0
- package/plugins/devflow-core/skills/workflow-dynamic/SKILL.md +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +37 -50
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +46 -191
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/artifact_specs.rb +12 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dynamic_plan.rb +504 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +38 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_policy.rb +11 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +12 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +4 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +19 -86
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +24 -13
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +4 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +7 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +17 -39
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +39 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +2 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +0 -1
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +13 -5
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +1 -1
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-api-call-guide/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +3 -1
- package/plugins/devflow-design/skills/workflow-api-supplement/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +6 -4
- package/plugins/devflow-design/skills/workflow-design-rule-pack/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-feature-breakdown/SKILL.md +2 -0
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -4
- package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +3 -1
- package/plugins/devflow-design/skills/workflow-ui-spec/SKILL.md +4 -2
- package/plugins/devflow-implementation/agents/implementation-worker.md +2 -8
- package/plugins/devflow-implementation/skills/workflow-bug-fix/SKILL.md +5 -0
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +3 -1
- package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +6 -1
- package/plugins/devflow-intel/skills/code-index-query/SKILL.md +2 -0
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +2 -0
- package/plugins/devflow-intel/skills/workflow-context-sync/SKILL.md +2 -0
- package/plugins/devflow-intel/skills/workflow-update-navigation/SKILL.md +3 -1
- package/plugins/devflow-quality/agents/code-reviewer.md +2 -0
- package/plugins/devflow-quality/skills/code-review/SKILL.md +7 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -1
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +7 -5
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +11 -6
- package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +2 -0
- package/plugins/devflow-requirements/skills/workflow-demand-clarify/SKILL.md +2 -0
- package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +2 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +6 -2
- package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +5 -0
- package/plugins/devflow-requirements/skills/workflow-wayfinder/SKILL.md +2 -0
- package/src/constants.ts +1 -1
- package/src/dynamic-workflow.ts +31 -0
- 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"], "
|
|
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["
|
|
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", {})),
|