@guanlin4924/devflow-pi 0.1.83 → 0.1.85
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +6 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +28 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +7 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +30 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +0 -3
- package/src/constants.ts +1 -1
package/package.json
CHANGED
|
@@ -968,7 +968,9 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
|
|
|
968
968
|
|
|
969
969
|
const feature = typeof snapshot.current_feature === "string" ? snapshot.current_feature : "";
|
|
970
970
|
const revision = snapshot.revision;
|
|
971
|
-
const selectedStep =
|
|
971
|
+
const selectedStep = snapshot.action === "run_parallel_window"
|
|
972
|
+
? compactStep(snapshot.next_step) ?? compactStep(snapshot.current_step)
|
|
973
|
+
: compactStep(snapshot.current_step) ?? compactStep(snapshot.next_step);
|
|
972
974
|
const stepId = typeof selectedStep?.id === "string" ? selectedStep.id : "";
|
|
973
975
|
if (!feature || !stepId || typeof revision !== "number" || !Number.isInteger(revision)) return "idle";
|
|
974
976
|
|
|
@@ -985,7 +987,9 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
|
|
|
985
987
|
}
|
|
986
988
|
|
|
987
989
|
function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string): string {
|
|
988
|
-
const selectedStep =
|
|
990
|
+
const selectedStep = snapshot.action === "run_parallel_window"
|
|
991
|
+
? compactStep(snapshot.next_step) ?? compactStep(snapshot.current_step)
|
|
992
|
+
: compactStep(snapshot.current_step) ?? compactStep(snapshot.next_step);
|
|
989
993
|
const envelope = {
|
|
990
994
|
source,
|
|
991
995
|
action: snapshot.action,
|
|
@@ -15,6 +15,12 @@ POLICY = {
|
|
|
15
15
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check business-closure-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
16
16
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
17
17
|
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
18
|
+
"backend-tech-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
19
|
+
"api-call-guide" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
20
|
+
"ui-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
21
|
+
"feature-breakdown" => { "checkers" => %w[requirement-graph-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule] },
|
|
22
|
+
"frontend-tech-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
23
|
+
"api-supplement" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
18
24
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
19
25
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
20
26
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
@@ -45,6 +51,27 @@ def interpolate(value, state)
|
|
|
45
51
|
value.gsub("{feature}", state.fetch("current_feature").to_s).gsub("{phase}", state.fetch("current_phase").to_s)
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
def render_execution_contract_markdown(contract)
|
|
55
|
+
lines = [
|
|
56
|
+
"# Execution Contract",
|
|
57
|
+
"",
|
|
58
|
+
"> Core-generated derived view. The immutable contract JSON under `.planning/contracts/` is authoritative.",
|
|
59
|
+
"",
|
|
60
|
+
"- Contract ID: `#{contract.fetch("contract_id")}`",
|
|
61
|
+
"- Feature: `#{contract.fetch("feature")}`",
|
|
62
|
+
"- Step: `#{contract.fetch("step")}`",
|
|
63
|
+
"- STATE revision: `#{contract.fetch("state_revision")}`",
|
|
64
|
+
"- Requirement graph revision: `#{contract.fetch("graph_revision")}`",
|
|
65
|
+
"- Frozen hash: `#{contract.fetch("frozen_hash")}`",
|
|
66
|
+
"",
|
|
67
|
+
"## Allowed paths",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
lines.concat(Array(contract["allowed_paths"]).map { |path| "- `#{path}`" })
|
|
71
|
+
lines.concat(["", "## Allowed agents", "", *Array(contract["allowed_agents"]).map { |agent| "- `#{agent}`" }, "", "## Checkers", "", *Array(contract["checker_ids"]).map { |checker| "- `#{checker}`" }, "", "## Allowed tools", "", *Array(contract["allowed_tools"]).map { |tool| "- `#{tool}`" }, ""])
|
|
72
|
+
lines.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
48
75
|
root = Dir.pwd
|
|
49
76
|
lock = %w[create inspect].include?(command) ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
50
77
|
begin
|
|
@@ -92,6 +119,7 @@ begin
|
|
|
92
119
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
93
120
|
destination = File.join(root, ".planning/contracts", options[:feature], "#{contract_id}.json")
|
|
94
121
|
DevflowControlIO.atomic_write(destination, JSON.pretty_generate(frozen) + "\n")
|
|
122
|
+
DevflowControlIO.atomic_write(File.join(root, ".planning/execution-contract.md"), render_execution_contract_markdown(frozen) + "\n")
|
|
95
123
|
audit = File.join(root, ".planning/contracts", options[:feature], "control-events.jsonl")
|
|
96
124
|
existing = File.exist?(audit) ? File.read(audit) : ""
|
|
97
125
|
DevflowControlIO.atomic_write(audit, existing + JSON.generate({ "event" => "execution_contract_frozen", "contract_id" => contract_id, "state_revision" => state["revision"], "graph_revision" => graph["revision"], "frozen_hash" => frozen["frozen_hash"] }) + "\n")
|
|
@@ -212,6 +212,10 @@ def track_boundary(state, pending_ids, completed_ids)
|
|
|
212
212
|
end
|
|
213
213
|
|
|
214
214
|
def active_parallel_window(workflow, state, root, step_by_id, completed_ids, pending_ids, artifact_by_path)
|
|
215
|
+
# A failed/unsupported parallel dispatch may be explicitly converted to the
|
|
216
|
+
# declared serial fallback. Keep the join topology in the workflow, but do
|
|
217
|
+
# not re-enter the same parallel window on every inspection.
|
|
218
|
+
return nil if state["parallel_execution_mode"].to_s == "serial_fallback"
|
|
215
219
|
Array(workflow["parallel_windows"]).each do |window|
|
|
216
220
|
after_step = window["after_step"]
|
|
217
221
|
branch_map = window["branches"].is_a?(Hash) ? window["branches"] : {}
|
|
@@ -206,7 +206,7 @@ if $PROGRAM_NAME == __FILE__
|
|
|
206
206
|
begin
|
|
207
207
|
options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
|
|
208
208
|
OptionParser.new do |opts|
|
|
209
|
-
opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
|
|
209
|
+
opts.on("--action ACTION", %w[inspect prepare update retry rebase serial-fallback]) { |value| options[:action] = value }
|
|
210
210
|
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
211
211
|
opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
|
|
212
212
|
opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
|
|
@@ -214,6 +214,8 @@ if $PROGRAM_NAME == __FILE__
|
|
|
214
214
|
opts.on("--status STATUS") { |value| options[:status] = value }
|
|
215
215
|
opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
|
|
216
216
|
opts.on("--state PATH") { |value| options[:state_path] = value }
|
|
217
|
+
opts.on("--step STEP") { |value| options[:serial_step] = value }
|
|
218
|
+
opts.on("--expected-state-revision REV", Integer) { |value| options[:expected_state_revision] = value }
|
|
217
219
|
opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
|
|
218
220
|
workflow_step, track = value.split(":", 2)
|
|
219
221
|
raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
|
|
@@ -233,7 +235,55 @@ if $PROGRAM_NAME == __FILE__
|
|
|
233
235
|
ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
|
|
234
236
|
when "retry"
|
|
235
237
|
ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
|
|
236
|
-
|
|
238
|
+
when "serial-fallback"
|
|
239
|
+
root = Dir.pwd
|
|
240
|
+
feature = ParallelRun.safe_feature(options[:feature])
|
|
241
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
242
|
+
begin
|
|
243
|
+
record = ParallelRun.load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
244
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state_path], :state)
|
|
245
|
+
state_text = File.read(state_path)
|
|
246
|
+
front = state_text.match(/\A---\n(.*?)\n---\n/m)&.[](1)
|
|
247
|
+
raise ArgumentError, "STATE.md has no YAML front matter" unless front
|
|
248
|
+
state = YAML.safe_load(front, permitted_classes: [], aliases: false)
|
|
249
|
+
raise ArgumentError, "STATE feature mismatch" unless state["current_feature"].to_s == feature
|
|
250
|
+
raise ArgumentError, "STATE does not reference this parallel run" unless state["parallel_run_ref"].to_s == record["run_id"].to_s
|
|
251
|
+
actual_revision = Integer(state.fetch("revision"))
|
|
252
|
+
if options[:expected_state_revision] && actual_revision != options[:expected_state_revision]
|
|
253
|
+
raise ArgumentError, "STATE revision conflict"
|
|
254
|
+
end
|
|
255
|
+
branches = Array(record["branches"])
|
|
256
|
+
raise ArgumentError, "serial fallback is only safe before any branch succeeds or runs" if branches.any? { |branch| %w[running succeeded].include?(branch["status"].to_s) }
|
|
257
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
258
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
259
|
+
policy = Array(workflow["parallel_windows"]).find { |window| window.is_a?(Hash) && window["id"].to_s == record["workflow_window"].to_s }&.dig("execution_policy", "serial_fallback_order")
|
|
260
|
+
order = Array(policy).map(&:to_s)
|
|
261
|
+
selected = options[:serial_step].to_s
|
|
262
|
+
selected = order.find do |step_id|
|
|
263
|
+
branch = branches.find { |item| item["workflow_step"].to_s == step_id }
|
|
264
|
+
branch && branch["status"].to_s == "pending"
|
|
265
|
+
end if selected.empty?
|
|
266
|
+
raise ArgumentError, "serial fallback has no pending branch" if selected.to_s.empty?
|
|
267
|
+
raise ArgumentError, "serial fallback step is not a pending branch" unless branches.any? { |branch| branch["workflow_step"].to_s == selected && branch["status"].to_s == "pending" }
|
|
268
|
+
|
|
269
|
+
state.delete("parallel_run_ref")
|
|
270
|
+
state["parallel_execution_mode"] = "serial_fallback"
|
|
271
|
+
state["current_step"] = selected
|
|
272
|
+
state["revision"] = actual_revision + 1
|
|
273
|
+
state["state_updated_at"] = Date.today.to_s
|
|
274
|
+
body = state_text.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
|
|
275
|
+
DevflowControlIO.atomic_write(state_path, body)
|
|
276
|
+
record["status"] = "abandoned_serial_fallback"
|
|
277
|
+
record["abandoned_at"] = Time.now.utc.iso8601
|
|
278
|
+
record["abandon_reason"] = "no child Agent lifecycle started; converted to declared serial_fallback_order"
|
|
279
|
+
record["serial_fallback_step"] = selected
|
|
280
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
281
|
+
ParallelRun.write(root, feature, record)
|
|
282
|
+
result = { "ok" => true, "state_revision" => state["revision"], "serial_step" => selected, "run" => record }
|
|
283
|
+
ensure
|
|
284
|
+
DevflowControlIO.release_lock(lock)
|
|
285
|
+
end
|
|
286
|
+
when "rebase"
|
|
237
287
|
raise ArgumentError, "--state-revision is required" if options[:state_revision].nil?
|
|
238
288
|
ParallelRun.rebase_state_revision(root: Dir.pwd, feature: options[:feature], state_revision: options[:state_revision])
|
|
239
289
|
.then { |run| run ? { "ok" => true, "run" => run } : { "ok" => false, "reason" => "parallel_run_not_found" } }
|
|
@@ -175,7 +175,13 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
175
175
|
return nil
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
raw = File.read(path)
|
|
179
|
+
if code == "workflow_config"
|
|
180
|
+
WorkflowGateContract.duplicate_yaml_keys(raw).each do |key_path|
|
|
181
|
+
issues << issue("error", "duplicate_workflow_yaml_key", "Workflow contains a duplicate YAML mapping key", path: key_path)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
YAML.safe_load(raw, permitted_classes: [], aliases: false)
|
|
179
185
|
rescue Psych::SyntaxError => e
|
|
180
186
|
issues << issue("error", "invalid_#{code}", e.message, path: path)
|
|
181
187
|
nil
|
|
@@ -14,6 +14,31 @@ module WorkflowGateContract
|
|
|
14
14
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
|
+
# Psych silently keeps the last value for duplicate mapping keys. Detect
|
|
18
|
+
# duplicates before loading a workflow so a checker declaration can never be
|
|
19
|
+
# overwritten without a gate failure.
|
|
20
|
+
def duplicate_yaml_keys(yaml)
|
|
21
|
+
duplicates = []
|
|
22
|
+
walk = lambda do |node, path|
|
|
23
|
+
return unless node
|
|
24
|
+
if node.is_a?(Psych::Nodes::Mapping)
|
|
25
|
+
seen = {}
|
|
26
|
+
children = node.children
|
|
27
|
+
children.each_slice(2) do |key_node, value_node|
|
|
28
|
+
key = key_node.respond_to?(:value) ? key_node.value.to_s : key_node.to_s
|
|
29
|
+
key_path = path.empty? ? key : "#{path}.#{key}"
|
|
30
|
+
duplicates << key_path if seen.key?(key)
|
|
31
|
+
seen[key] = true
|
|
32
|
+
walk.call(value_node, key_path)
|
|
33
|
+
end
|
|
34
|
+
elsif node.respond_to?(:children)
|
|
35
|
+
Array(node.children).each { |child| walk.call(child, path) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
Psych.parse_stream(yaml).children.each { |document| walk.call(document.root, "") }
|
|
39
|
+
duplicates.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
17
42
|
def validate(workflow)
|
|
18
43
|
errors = []
|
|
19
44
|
unless workflow.is_a?(Hash)
|
|
@@ -139,7 +164,11 @@ if $PROGRAM_NAME == __FILE__
|
|
|
139
164
|
opts.on("--json") { options[:json] = true }
|
|
140
165
|
end.parse!
|
|
141
166
|
raise ArgumentError, "--workflow is required" unless options[:workflow]
|
|
142
|
-
|
|
167
|
+
raw = File.read(options[:workflow])
|
|
168
|
+
duplicate_errors = WorkflowGateContract.duplicate_yaml_keys(raw).map do |path|
|
|
169
|
+
WorkflowGateContract.error("DUPLICATE_YAML_KEY", "workflow contains a duplicate YAML mapping key", path: path)
|
|
170
|
+
end
|
|
171
|
+
result = duplicate_errors + WorkflowGateContract.validate(YAML.safe_load(raw, permitted_classes: [], aliases: false))
|
|
143
172
|
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
144
173
|
rescue StandardError => e
|
|
145
174
|
warn e.message
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -70,10 +70,7 @@ governance:
|
|
|
70
70
|
- api-test-matrix-check
|
|
71
71
|
- test-coverage-check
|
|
72
72
|
- acceptance-test-results-check
|
|
73
|
-
- change-impact-check
|
|
74
|
-
run-acceptance-tests:
|
|
75
73
|
- acceptance-coverage-check
|
|
76
|
-
- acceptance-test-results-check
|
|
77
74
|
- change-impact-check
|
|
78
75
|
|
|
79
76
|
artifact_registry:
|
package/src/constants.ts
CHANGED