@guanlin4924/devflow-pi 0.1.82 → 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 +31 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +7 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +37 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +57 -2
- 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/review_run.py +472 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +31 -3
- 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 +28 -7
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +1 -1
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +2 -0
- 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,
|
|
@@ -3818,6 +3822,31 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
3818
3822
|
},
|
|
3819
3823
|
});
|
|
3820
3824
|
|
|
3825
|
+
pi.registerTool({
|
|
3826
|
+
name: "devflow_review_run",
|
|
3827
|
+
label: "Devflow Fact-Bound Review",
|
|
3828
|
+
description: "Run an independent, read-only review against approved expectations and hash-verified source evidence. Core records immutable rounds; this does not approve a release.",
|
|
3829
|
+
parameters: z.object({
|
|
3830
|
+
chain_id: z.string().nullable().optional(),
|
|
3831
|
+
target: z.object({ feature: z.string(), paths: z.array(z.string()), change_ref: z.literal("worktree") }),
|
|
3832
|
+
expectations: z.array(z.object({ id: z.string(), statement: z.string(), authority_ref: z.string() })),
|
|
3833
|
+
review_plan: z.object({ questions: z.array(z.string()), surfaces: z.array(z.string()), required_scenarios: z.array(z.string()) }),
|
|
3834
|
+
observations: z.array(z.unknown()).default([]),
|
|
3835
|
+
coverage_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
3836
|
+
regression_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
3837
|
+
}),
|
|
3838
|
+
approval: "write",
|
|
3839
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3840
|
+
assertRuntimeProjectBoundary(ctx.cwd);
|
|
3841
|
+
return renderResult(await runProcess(
|
|
3842
|
+
["python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_run.py"), ctx.cwd],
|
|
3843
|
+
ctx.cwd,
|
|
3844
|
+
{},
|
|
3845
|
+
JSON.stringify(params),
|
|
3846
|
+
));
|
|
3847
|
+
},
|
|
3848
|
+
});
|
|
3849
|
+
|
|
3821
3850
|
pi.registerTool({
|
|
3822
3851
|
name: "devflow_artifact",
|
|
3823
3852
|
label: "Devflow Artifact Registry",
|
|
@@ -112,8 +112,14 @@ def transition_gate_contract(workflow, step)
|
|
|
112
112
|
"allowed_reentry" => reentries,
|
|
113
113
|
"checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
|
|
114
114
|
},
|
|
115
|
+
# Inputs can be produced by the step itself. Keep one declaration, but
|
|
116
|
+
# give the gate separate entry/completion semantics so an input may be
|
|
117
|
+
# absent on entry while still being mandatory before completion.
|
|
115
118
|
"entry" => { "required_inputs" => Array(step["inputs"]) },
|
|
116
|
-
"outputs" => {
|
|
119
|
+
"outputs" => {
|
|
120
|
+
"required" => Array(step["outputs"]).select { |output| output["required"] },
|
|
121
|
+
"required_inputs" => Array(step["inputs"]).select { |input| input.is_a?(Hash) && input["required_at_completion"] == true }
|
|
122
|
+
},
|
|
117
123
|
"quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
|
|
118
124
|
"concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
|
|
119
125
|
"recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
|
|
@@ -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
|
|
@@ -70,7 +97,15 @@ begin
|
|
|
70
97
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
|
|
71
98
|
"workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
|
|
72
99
|
"allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
|
|
73
|
-
|
|
100
|
+
# Core read/write tools required by the architecture feasibility skill are
|
|
101
|
+
# part of the frozen contract. The tool layer still applies per-tool
|
|
102
|
+
# mutation ownership and the step's allowed paths.
|
|
103
|
+
"allowed_tools" => %w[
|
|
104
|
+
read grep find glob lsp search write edit apply_patch bash shell exec_command task
|
|
105
|
+
devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
|
|
106
|
+
devflow_requirement_graph devflow_static_runtime_context
|
|
107
|
+
devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
|
|
108
|
+
],
|
|
74
109
|
"tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
|
|
75
110
|
"allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
|
|
76
111
|
"on_unknown" => "block_and_escalate"
|
|
@@ -84,6 +119,7 @@ begin
|
|
|
84
119
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
85
120
|
destination = File.join(root, ".planning/contracts", options[:feature], "#{contract_id}.json")
|
|
86
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")
|
|
87
123
|
audit = File.join(root, ".planning/contracts", options[:feature], "control-events.jsonl")
|
|
88
124
|
existing = File.exist?(audit) ? File.read(audit) : ""
|
|
89
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")
|
|
@@ -7,8 +7,10 @@ require_relative "gate_contract"
|
|
|
7
7
|
require_relative "checker_registry"
|
|
8
8
|
require_relative "parallel_run"
|
|
9
9
|
require_relative "business_model_gate"
|
|
10
|
+
require_relative "requirement_graph"
|
|
10
11
|
|
|
11
12
|
module GateRuntime
|
|
13
|
+
KNOWN_VALIDATIONS = %w[static-runtime-context requirement-graph].freeze
|
|
12
14
|
WORKFLOW_STATUSES = %w[not_started in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
|
|
13
15
|
|
|
14
16
|
module_function
|
|
@@ -59,11 +61,16 @@ module GateRuntime
|
|
|
59
61
|
return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
|
|
60
62
|
end
|
|
61
63
|
if root
|
|
62
|
-
missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state, allow_missing_collections: true)
|
|
64
|
+
missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state, allow_missing_collections: true, purpose: :entry)
|
|
63
65
|
return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
|
|
66
|
+
invalid = validate_declared_inputs(contract.dig("entry", "required_inputs"), root, workflow_state, purpose: :entry)
|
|
67
|
+
return GateResult.block(**base, code: "REQUIRED_INPUT_INVALID", message: "required gate input is invalid", details: { "invalid" => invalid }) unless invalid.empty?
|
|
64
68
|
if check_outputs
|
|
65
69
|
missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
|
|
70
|
+
missing_outputs.concat(required_paths(contract.dig("outputs", "required_inputs"), root, workflow_state, purpose: :completion))
|
|
66
71
|
return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
|
|
72
|
+
invalid_outputs = validate_declared_inputs(contract.dig("outputs", "required_inputs"), root, workflow_state, purpose: :completion)
|
|
73
|
+
return GateResult.block(**base, code: "REQUIRED_OUTPUT_INVALID", message: "required step input/output fact is invalid", details: { "invalid" => invalid_outputs }) unless invalid_outputs.empty?
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
end
|
|
@@ -148,9 +155,14 @@ module GateRuntime
|
|
|
148
155
|
)
|
|
149
156
|
end
|
|
150
157
|
|
|
151
|
-
def required_paths(entries, root, state, allow_missing_collections: false)
|
|
158
|
+
def required_paths(entries, root, state, allow_missing_collections: false, purpose: :entry)
|
|
152
159
|
Array(entries).each_with_object([]) do |entry, missing|
|
|
153
160
|
raw = entry.is_a?(Hash) ? entry["path"] : entry
|
|
161
|
+
if entry.is_a?(Hash)
|
|
162
|
+
next if purpose == :entry && entry["required"] == false
|
|
163
|
+
next if purpose == :entry && entry.key?("required_at_entry") && entry["required_at_entry"] == false
|
|
164
|
+
next if purpose == :completion && entry["required_at_completion"] != true
|
|
165
|
+
end
|
|
154
166
|
next if raw.to_s.empty?
|
|
155
167
|
relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
156
168
|
absolute = File.expand_path(relative, root)
|
|
@@ -163,4 +175,47 @@ module GateRuntime
|
|
|
163
175
|
missing << relative
|
|
164
176
|
end
|
|
165
177
|
end
|
|
178
|
+
|
|
179
|
+
def validate_declared_inputs(entries, root, state, purpose: :entry)
|
|
180
|
+
Array(entries).each_with_object([]) do |entry, invalid|
|
|
181
|
+
next unless entry.is_a?(Hash)
|
|
182
|
+
next if purpose == :completion && entry["required_at_completion"] != true
|
|
183
|
+
next if purpose == :entry && entry["required"] == false
|
|
184
|
+
next if purpose == :entry && entry.key?("required_at_entry") && entry["required_at_entry"] == false
|
|
185
|
+
validation = entry["validation"].to_s
|
|
186
|
+
next if validation.empty?
|
|
187
|
+
unless KNOWN_VALIDATIONS.include?(validation)
|
|
188
|
+
invalid << "#{entry["path"]}: unknown validation #{validation}"
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
raw = entry["path"].to_s
|
|
192
|
+
relative = raw.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
193
|
+
absolute = File.expand_path(relative, root)
|
|
194
|
+
begin
|
|
195
|
+
payload = JSON.parse(File.read(absolute))
|
|
196
|
+
case validation
|
|
197
|
+
when "static-runtime-context"
|
|
198
|
+
pointer_path = payload.fetch("path")
|
|
199
|
+
raise "invalid pointer path" unless pointer_path.start_with?(".context/static-runtime-context/revisions/")
|
|
200
|
+
root_real = File.realpath(root)
|
|
201
|
+
revisions_root = File.realpath(File.join(root, ".context/static-runtime-context/revisions"))
|
|
202
|
+
snapshot_real = File.realpath(File.expand_path(pointer_path, root))
|
|
203
|
+
raise "snapshot path escapes project root" unless snapshot_real.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
204
|
+
raise "snapshot path escapes revisions directory" unless snapshot_real.start_with?("#{revisions_root}#{File::SEPARATOR}")
|
|
205
|
+
raise "snapshot is not a regular file" unless File.file?(snapshot_real)
|
|
206
|
+
snapshot = JSON.parse(File.read(snapshot_real))
|
|
207
|
+
raise "unsupported schema" unless snapshot["schema_version"] == 2
|
|
208
|
+
raise "collector identity missing" unless snapshot["collector_version"] == "static-runtime-context-v2.1"
|
|
209
|
+
raise "revision mismatch" unless payload["current_revision"] == snapshot["snapshot_revision"] && snapshot["snapshot_revision"].is_a?(Integer) && snapshot["snapshot_revision"] > 0
|
|
210
|
+
when "requirement-graph"
|
|
211
|
+
raise "unsupported graph schema" unless RequirementGraph::SUPPORTED_SCHEMA_VERSIONS.include?(payload["schema_version"])
|
|
212
|
+
raise "invalid graph shape" unless payload["feature"] == state.fetch("current_feature") && payload["revision"].is_a?(Integer) && payload["nodes"].is_a?(Array) && payload["edges"].is_a?(Array) && payload["stale"].is_a?(Array)
|
|
213
|
+
graph_errors = RequirementGraph.validate(payload)
|
|
214
|
+
raise "graph validation failed: #{graph_errors.join("; ")}" unless graph_errors.empty?
|
|
215
|
+
end
|
|
216
|
+
rescue StandardError => error
|
|
217
|
+
invalid << "#{relative}: #{error.message}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
166
221
|
end
|
|
@@ -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" } }
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fact-bound, append-only review ledger. Input is one JSON object on stdin.
|
|
3
|
+
|
|
4
|
+
This service validates review evidence; it does not execute untrusted review plans or
|
|
5
|
+
pretend that a source excerpt alone proves runtime behaviour.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import sqlite3
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
SURFACES = {"entry", "service", "storage", "tests"}
|
|
21
|
+
SCENARIOS = {"normal", "unauthorized", "concurrency", "failure_rollback", "compatibility"}
|
|
22
|
+
IMPACTS = {
|
|
23
|
+
"cross_boundary_exposure": "P0", "irrecoverable_data_loss": "P0",
|
|
24
|
+
"major_security_failure": "P0", "core_promise_failure": "P0",
|
|
25
|
+
"critical_behavior": "P1", "important_coverage_gap": "P1",
|
|
26
|
+
"local_correctness": "P2", "compatibility": "P2", "maintainability": "P2",
|
|
27
|
+
"low_risk_improvement": "P3",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digest(data: bytes) -> str:
|
|
32
|
+
return hashlib.sha256(data).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def canonical(value: Any) -> str:
|
|
36
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def safe_file(root: Path, relative: str) -> Path:
|
|
40
|
+
if not isinstance(relative, str) or not relative or Path(relative).is_absolute() or "\x00" in relative:
|
|
41
|
+
raise ValueError("path must be a nonempty project-relative path")
|
|
42
|
+
target = (root / relative).resolve(strict=True)
|
|
43
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
44
|
+
raise ValueError(f"path escapes project or is not a file: {relative}")
|
|
45
|
+
return target
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_json(path: Path) -> Any:
|
|
49
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def source_evidence(root: Path, evidence: dict) -> dict:
|
|
53
|
+
relative = evidence.get("path")
|
|
54
|
+
path = safe_file(root, relative)
|
|
55
|
+
data = path.read_bytes()
|
|
56
|
+
expected = evidence.get("sha256")
|
|
57
|
+
if not isinstance(expected, str) or expected != digest(data):
|
|
58
|
+
raise StaleEvidence(f"source hash mismatch: {relative}")
|
|
59
|
+
line = evidence.get("line")
|
|
60
|
+
lines = data.decode("utf-8").splitlines()
|
|
61
|
+
if type(line) is not int or line < 1 or line > len(lines):
|
|
62
|
+
raise ValueError(f"invalid source line: {relative}:{line}")
|
|
63
|
+
excerpt = evidence.get("excerpt")
|
|
64
|
+
if not isinstance(excerpt, str) or not excerpt or excerpt not in lines[line - 1]:
|
|
65
|
+
raise StaleEvidence(f"source line mismatch: {relative}:{line}")
|
|
66
|
+
return {"path": relative, "line": line, "sha256": expected, "excerpt": excerpt}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StaleEvidence(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_authority(root: Path, feature: str, expectations: list) -> tuple[list, list]:
|
|
74
|
+
issues = []
|
|
75
|
+
accepted = []
|
|
76
|
+
for item in expectations:
|
|
77
|
+
if not isinstance(item, dict) or not all(isinstance(item.get(k), str) and item[k] for k in ("id", "statement", "authority_ref")):
|
|
78
|
+
issues.append("Each expectation needs id, statement, and authority_ref")
|
|
79
|
+
continue
|
|
80
|
+
ref = item["authority_ref"]
|
|
81
|
+
try:
|
|
82
|
+
# Only the Core-owned governance graph can authorize an expectation.
|
|
83
|
+
# Ordinary approved:true JSON is never an authority source.
|
|
84
|
+
if not ref.startswith("core-graph:") or "@" not in ref:
|
|
85
|
+
issues.append(f"{item['id']}: authority_ref must identify a Core graph node and revision")
|
|
86
|
+
continue
|
|
87
|
+
node_id, revision = ref[len("core-graph:"):].rsplit("@", 1)
|
|
88
|
+
graph_path = f".planning/requirements/{feature}/governance.json"
|
|
89
|
+
graph_file = safe_file(root, graph_path)
|
|
90
|
+
authority = read_json(graph_file)
|
|
91
|
+
node = next((n for n in authority.get("nodes", []) if isinstance(n, dict) and n.get("id") == node_id), None)
|
|
92
|
+
data = node.get("data", {}) if node else {}
|
|
93
|
+
values = {data.get(k) for k in ("statement", "text", "name", "description") if isinstance(data.get(k), str)}
|
|
94
|
+
stale_ids = {n.get("node_id") for n in authority.get("stale", []) if isinstance(n, dict)}
|
|
95
|
+
if (authority.get("feature") != feature or str(authority.get("revision")) != revision or not node
|
|
96
|
+
or node.get("kind") not in {"requirement", "rule", "boundary", "acceptance"}
|
|
97
|
+
or node.get("status") != "confirmed" or node.get("confirmation", {}).get("origin") != "core"
|
|
98
|
+
or not node.get("confirmation", {}).get("evidence") or node_id in stale_ids
|
|
99
|
+
or item["statement"] not in values):
|
|
100
|
+
issues.append(f"{item['id']}: Core graph node is unconfirmed, stale, mismatched, or has no exact statement: {ref}")
|
|
101
|
+
else:
|
|
102
|
+
accepted.append({**item, "authority_revision": revision, "authority_sha256": digest(graph_file.read_bytes()), "authority_path": graph_path, "node_id": node_id})
|
|
103
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
104
|
+
issues.append(f"{item['id']}: unreadable authority {ref}: {exc}")
|
|
105
|
+
if not expectations:
|
|
106
|
+
issues.append("At least one approved expectation is required")
|
|
107
|
+
return accepted, issues
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def db_open(root: Path) -> sqlite3.Connection:
|
|
111
|
+
planning = root / ".planning"
|
|
112
|
+
if planning.is_symlink():
|
|
113
|
+
raise ValueError("unsafe planning directory")
|
|
114
|
+
directory = planning / "review-runs"
|
|
115
|
+
if directory.is_symlink():
|
|
116
|
+
raise ValueError("unsafe review ledger directory")
|
|
117
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
if directory.is_symlink() or not directory.resolve().is_relative_to(root):
|
|
119
|
+
raise ValueError("unsafe review ledger directory")
|
|
120
|
+
database_path = directory / "review-runs.sqlite"
|
|
121
|
+
if database_path.is_symlink():
|
|
122
|
+
raise ValueError("unsafe review ledger database")
|
|
123
|
+
db = sqlite3.connect(database_path, timeout=10)
|
|
124
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
125
|
+
db.executescript("""
|
|
126
|
+
CREATE TABLE IF NOT EXISTS chains (
|
|
127
|
+
chain_id TEXT PRIMARY KEY, target_key TEXT NOT NULL, authority_key TEXT NOT NULL,
|
|
128
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, terminal TEXT NOT NULL DEFAULT ''
|
|
129
|
+
);
|
|
130
|
+
CREATE TABLE IF NOT EXISTS rounds (
|
|
131
|
+
chain_id TEXT NOT NULL, round INTEGER NOT NULL, result_json TEXT NOT NULL,
|
|
132
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
133
|
+
PRIMARY KEY(chain_id, round), FOREIGN KEY(chain_id) REFERENCES chains(chain_id)
|
|
134
|
+
);
|
|
135
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
136
|
+
id INTEGER PRIMARY KEY, chain_id TEXT, status TEXT NOT NULL, detail_json TEXT NOT NULL,
|
|
137
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
138
|
+
);
|
|
139
|
+
""")
|
|
140
|
+
return db
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def response(status: str, chain_id: str | None, round_no: int, **details: Any) -> dict:
|
|
144
|
+
return {"status": status, "chain_id": chain_id, "round": round_no, **details}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def locate_index(root: Path, accepted: list, target_paths: list[str], questions: list[str]) -> dict:
|
|
148
|
+
"""Ask the index for bounded leads, never for authoritative conclusions."""
|
|
149
|
+
index = root / ".planning" / "code-index"
|
|
150
|
+
script = Path(__file__).resolve().parents[3].parent / "devflow-intel" / "skills" / "workflow-code-index" / "scripts" / "code_index.py"
|
|
151
|
+
if not index.exists() or not script.exists():
|
|
152
|
+
return {"status": "unavailable", "reason": "No local code-index snapshot; source evidence is independent"}
|
|
153
|
+
leads = []
|
|
154
|
+
generation = None
|
|
155
|
+
tokens = []
|
|
156
|
+
for candidate in [*questions, *(e["statement"] for e in accepted)]:
|
|
157
|
+
tokens.extend(re.findall(r"[A-Za-z_][A-Za-z_0-9]{2,}|[\u3400-\u9fff]{2,12}", candidate))
|
|
158
|
+
terms = list(dict.fromkeys(tokens))[:16]
|
|
159
|
+
if not terms:
|
|
160
|
+
return {"status": "unavailable", "reason": "No bounded search terms could be extracted"}
|
|
161
|
+
for expectation in accepted[:8]:
|
|
162
|
+
plan = {"featureTerms": terms, "identifiers": terms, "phases": ["search_symbols", "search_content", "verify_evidence"], "limit": 50}
|
|
163
|
+
try:
|
|
164
|
+
result = subprocess.run([sys.executable, str(script), "query-plan", "--index", str(index), "--plan", canonical(plan)], cwd=root, text=True, capture_output=True, timeout=8, check=False)
|
|
165
|
+
payload = json.loads(result.stdout)
|
|
166
|
+
if result.returncode or payload.get("_meta", {}).get("status") == "stale_index":
|
|
167
|
+
return {"status": "unavailable", "reason": payload.get("_meta", {}).get("reason", "Index stale or query unavailable")}
|
|
168
|
+
meta = payload.get("_meta", {})
|
|
169
|
+
current = meta.get("snapshot") or meta.get("code_generation")
|
|
170
|
+
if generation and current and generation != current:
|
|
171
|
+
return {"status": "stale_evidence", "reason": "Index generation changed between queries"}
|
|
172
|
+
generation = current or generation
|
|
173
|
+
verified = []
|
|
174
|
+
for hit in payload.get("results", []):
|
|
175
|
+
if not isinstance(hit, dict) or hit.get("path") not in target_paths:
|
|
176
|
+
continue
|
|
177
|
+
relative = hit["path"]
|
|
178
|
+
try:
|
|
179
|
+
source = safe_file(root, relative)
|
|
180
|
+
verified.append({"path": relative, "source_sha256": digest(source.read_bytes()), "index_entity_version_id": hit.get("entity_version_id"), "index_snapshot": hit.get("snapshot"), "result_type": hit.get("result_type")})
|
|
181
|
+
except (OSError, ValueError):
|
|
182
|
+
continue
|
|
183
|
+
leads.append({"expectation_id": expectation["id"], "verified_target_leads": verified, "index_status": meta.get("status")})
|
|
184
|
+
except (OSError, subprocess.TimeoutExpired, ValueError, json.JSONDecodeError) as exc:
|
|
185
|
+
return {"status": "unavailable", "reason": str(exc)}
|
|
186
|
+
return {"status": "located", "generation": generation, "leads": leads}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def verify_core_result(root: Path, feature: str, evidence: Any, expected_status: str, authority_node_id: str | None = None) -> tuple[bool, str]:
|
|
190
|
+
"""Accept only a Core-owned TC result bound to the feature and frozen suite."""
|
|
191
|
+
if not isinstance(evidence, dict):
|
|
192
|
+
return False, "missing Core acceptance-test result reference"
|
|
193
|
+
relative = f".planning/test-runs/{feature}/acceptance-results.json"
|
|
194
|
+
if evidence.get("path") != relative or not isinstance(evidence.get("tc_id"), str):
|
|
195
|
+
return False, "result must reference a Core acceptance TC for this feature"
|
|
196
|
+
try:
|
|
197
|
+
result_file = safe_file(root, relative)
|
|
198
|
+
if digest(result_file.read_bytes()) != evidence.get("sha256"):
|
|
199
|
+
return False, "acceptance result hash mismatch"
|
|
200
|
+
payload = read_json(result_file)
|
|
201
|
+
suite = read_json(safe_file(root, f".planning/requirements/{feature}/tests.json"))
|
|
202
|
+
if payload.get("origin") != "core_acceptance_test_runner" or payload.get("feature") != feature:
|
|
203
|
+
return False, "not a Core acceptance-test run"
|
|
204
|
+
if payload.get("test_suite") != {"graph_revision": suite.get("graph_revision"), "fingerprint": suite.get("view_meta", {}).get("fingerprint")}:
|
|
205
|
+
return False, "acceptance result is not bound to the current frozen suite"
|
|
206
|
+
if suite.get("frozen") is not True:
|
|
207
|
+
return False, "acceptance suite is not frozen"
|
|
208
|
+
frozen_nodes = {node.get("id"): node for node in suite.get("nodes", []) if isinstance(node, dict) and node.get("kind") == "test_case" and node.get("status") == "confirmed"}
|
|
209
|
+
if evidence["tc_id"] not in frozen_nodes:
|
|
210
|
+
return False, "TC is not in the frozen suite"
|
|
211
|
+
if authority_node_id:
|
|
212
|
+
refs = frozen_nodes[evidence["tc_id"]].get("data", {})
|
|
213
|
+
bound = set(refs.get("requirement_refs", [])) | set(refs.get("acceptance_refs", []))
|
|
214
|
+
if authority_node_id not in bound:
|
|
215
|
+
return False, "failed TC is not bound to the expectation's Core requirement or acceptance node"
|
|
216
|
+
cases = [r for r in payload.get("results", []) if isinstance(r, dict) and r.get("tc_id") == evidence["tc_id"]]
|
|
217
|
+
if len(cases) != 1 or cases[0].get("status") != expected_status or cases[0].get("mode") != "automated":
|
|
218
|
+
return False, "TC status or automation mode does not support the assertion"
|
|
219
|
+
return True, ""
|
|
220
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
221
|
+
return False, f"unreadable Core acceptance result: {exc}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def verify_coverage(root: Path, feature: str, required: list[str], coverage_evidence: Any) -> tuple[set[str], list[str]]:
|
|
225
|
+
if not isinstance(coverage_evidence, dict):
|
|
226
|
+
return set(), ["Each review surface and scenario needs a distinct Core TC binding"]
|
|
227
|
+
covered, issues, used = set(), [], set()
|
|
228
|
+
for tag in required:
|
|
229
|
+
ref = coverage_evidence.get(tag)
|
|
230
|
+
if not isinstance(ref, dict) or not isinstance(ref.get("tc_id"), str):
|
|
231
|
+
issues.append(f"{tag}: missing Core TC binding")
|
|
232
|
+
continue
|
|
233
|
+
if ref["tc_id"] in used:
|
|
234
|
+
issues.append(f"{tag}: TC already used for a different coverage claim")
|
|
235
|
+
continue
|
|
236
|
+
used.add(ref["tc_id"])
|
|
237
|
+
valid, reason = verify_core_result(root, feature, ref, "passed")
|
|
238
|
+
if not valid:
|
|
239
|
+
issues.append(f"{tag}: {reason}")
|
|
240
|
+
continue
|
|
241
|
+
suite = read_json(safe_file(root, f".planning/requirements/{feature}/tests.json"))
|
|
242
|
+
node = next((n for n in suite.get("nodes", []) if isinstance(n, dict) and n.get("id") == ref["tc_id"]), None)
|
|
243
|
+
types = {"scenario:normal": {"api", "page"}, "scenario:unauthorized": {"permission"}, "scenario:failure_rollback": {"resilience", "boundary"}, "scenario:compatibility": {"migration"}}
|
|
244
|
+
if not node or node.get("kind") != "test_case" or node.get("status") != "confirmed" or node.get("data", {}).get("test_type") not in types.get(tag, set()):
|
|
245
|
+
issues.append(f"{tag}: frozen TC type cannot prove this coverage obligation; request human confirmation")
|
|
246
|
+
continue
|
|
247
|
+
covered.add(tag)
|
|
248
|
+
return covered, issues
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def suite_has_failures(root: Path, feature: str) -> bool:
|
|
252
|
+
try:
|
|
253
|
+
payload = read_json(safe_file(root, f".planning/test-runs/{feature}/acceptance-results.json"))
|
|
254
|
+
return any(isinstance(item, dict) and item.get("status") != "passed" for item in payload.get("results", []))
|
|
255
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run(root: Path, request: dict) -> dict:
|
|
260
|
+
root = root.resolve(strict=True)
|
|
261
|
+
if not isinstance(request, dict):
|
|
262
|
+
raise ValueError("request must be an object")
|
|
263
|
+
if "round" in request or "findings" in request or "severity" in request:
|
|
264
|
+
raise ValueError("Core assigns rounds and findings; caller-supplied authority fields are forbidden")
|
|
265
|
+
target = request.get("target")
|
|
266
|
+
if not isinstance(target, dict) or not isinstance(target.get("feature"), str) or not target["feature"]:
|
|
267
|
+
raise ValueError("target.feature is required")
|
|
268
|
+
if target.get("change_ref") != "worktree":
|
|
269
|
+
return response("evidence_unavailable", request.get("chain_id"), 0, questions=["Only verified worktree source is supported; historical source blobs are unavailable"])
|
|
270
|
+
paths = target.get("paths")
|
|
271
|
+
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
|
|
272
|
+
raise ValueError("target.paths must list reviewed source files")
|
|
273
|
+
plan = request.get("review_plan")
|
|
274
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("questions"), list) or not plan["questions"]:
|
|
275
|
+
raise ValueError("review_plan.questions must be nonempty")
|
|
276
|
+
if not all(isinstance(q, str) and q.strip() for q in plan["questions"]):
|
|
277
|
+
raise ValueError("review questions must be strings")
|
|
278
|
+
surfaces, scenarios = plan.get("surfaces"), plan.get("required_scenarios")
|
|
279
|
+
if not isinstance(surfaces, list) or not set(surfaces) <= SURFACES or not isinstance(scenarios, list) or not set(scenarios) <= SCENARIOS:
|
|
280
|
+
raise ValueError("review plan uses an unsupported surface or scenario")
|
|
281
|
+
# A plan is not an exclusion list: the standard adversarial coverage is required.
|
|
282
|
+
required = sorted({f"surface:{s}" for s in SURFACES} | {f"scenario:{s}" for s in SCENARIOS} | {f"question:{q}" for q in plan["questions"]})
|
|
283
|
+
expectations = request.get("expectations", [])
|
|
284
|
+
if not isinstance(expectations, list):
|
|
285
|
+
raise ValueError("expectations must be a list")
|
|
286
|
+
accepted, questions = validate_authority(root, target["feature"], expectations)
|
|
287
|
+
chain_id = request.get("chain_id")
|
|
288
|
+
if chain_id is not None and (not isinstance(chain_id, str) or not chain_id.startswith("rev_")):
|
|
289
|
+
raise ValueError("invalid chain_id")
|
|
290
|
+
target_key = digest(canonical({"feature": target["feature"], "paths": sorted(paths), "change_ref": "worktree"}).encode())
|
|
291
|
+
authority_key = digest(canonical(accepted).encode())
|
|
292
|
+
db = db_open(root)
|
|
293
|
+
try:
|
|
294
|
+
db.execute("BEGIN IMMEDIATE")
|
|
295
|
+
prior = None
|
|
296
|
+
if chain_id:
|
|
297
|
+
row = db.execute("SELECT target_key, authority_key, terminal FROM chains WHERE chain_id=?", (chain_id,)).fetchone()
|
|
298
|
+
if row is None:
|
|
299
|
+
raise ValueError("unknown chain_id")
|
|
300
|
+
if row[0] != target_key or (not questions and row[1] != authority_key):
|
|
301
|
+
raise ValueError("target or approved expectations changed; continue the original chain")
|
|
302
|
+
if row[2]:
|
|
303
|
+
raise ValueError(f"chain is terminal: {row[2]}")
|
|
304
|
+
row = db.execute("SELECT round, result_json FROM rounds WHERE chain_id=? ORDER BY round DESC LIMIT 1", (chain_id,)).fetchone()
|
|
305
|
+
if row:
|
|
306
|
+
prior = json.loads(row[1])
|
|
307
|
+
round_no = row[0] + 1
|
|
308
|
+
else:
|
|
309
|
+
round_no = 1
|
|
310
|
+
if round_no > 3:
|
|
311
|
+
raise ValueError("three completed review rounds exhausted")
|
|
312
|
+
else:
|
|
313
|
+
round_no = 1
|
|
314
|
+
if questions:
|
|
315
|
+
result = response("awaiting_human_confirmation", chain_id, round_no, questions=questions)
|
|
316
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
317
|
+
db.commit()
|
|
318
|
+
return result
|
|
319
|
+
if not chain_id:
|
|
320
|
+
# A new ID cannot erase an unresolved P0 for the same target and authority.
|
|
321
|
+
for old_id, raw in db.execute("SELECT c.chain_id,r.result_json FROM chains c JOIN rounds r ON r.chain_id=c.chain_id AND r.round=(SELECT MAX(round) FROM rounds WHERE chain_id=c.chain_id) WHERE c.target_key=? AND c.authority_key=?", (target_key, authority_key)):
|
|
322
|
+
old = json.loads(raw)
|
|
323
|
+
if old.get("risk_summary", {}).get("P0", 0) and not old.get("all_p0_resolved"):
|
|
324
|
+
raise ValueError(f"unresolved P0 belongs to existing chain {old_id}; human-authorized new task is required")
|
|
325
|
+
chain_id = "rev_" + secrets.token_hex(16)
|
|
326
|
+
db.execute("INSERT INTO chains(chain_id,target_key,authority_key) VALUES(?,?,?)", (chain_id, target_key, authority_key))
|
|
327
|
+
versions = {}
|
|
328
|
+
for rel in paths:
|
|
329
|
+
versions[rel] = digest(safe_file(root, rel).read_bytes())
|
|
330
|
+
index_discovery = locate_index(root, accepted, paths, plan["questions"])
|
|
331
|
+
if index_discovery["status"] == "stale_evidence":
|
|
332
|
+
result = response("stale_evidence", chain_id, round_no, questions=[index_discovery["reason"]])
|
|
333
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
334
|
+
db.commit()
|
|
335
|
+
return result
|
|
336
|
+
observations = request.get("observations", [])
|
|
337
|
+
if not isinstance(observations, list):
|
|
338
|
+
raise ValueError("observations must be a list")
|
|
339
|
+
findings = []
|
|
340
|
+
covered, coverage_issues = verify_coverage(root, target["feature"], required, request.get("coverage_evidence"))
|
|
341
|
+
evidence_issues = []
|
|
342
|
+
for item in observations:
|
|
343
|
+
if not isinstance(item, dict) or item.get("expectation_id") not in {e["id"] for e in accepted}:
|
|
344
|
+
raise ValueError("observation has unknown expectation")
|
|
345
|
+
try:
|
|
346
|
+
evidence = source_evidence(root, item.get("source", {}))
|
|
347
|
+
except StaleEvidence as exc:
|
|
348
|
+
result = response("stale_evidence", chain_id, round_no, questions=[str(exc)])
|
|
349
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
350
|
+
db.commit()
|
|
351
|
+
return result
|
|
352
|
+
if evidence["path"] not in versions:
|
|
353
|
+
raise ValueError("observation source is outside target.paths")
|
|
354
|
+
tags = item.get("covers", [])
|
|
355
|
+
if not isinstance(tags, list) or not all(isinstance(t, str) and t in required for t in tags):
|
|
356
|
+
raise ValueError("observation has invalid coverage tags")
|
|
357
|
+
# Caller tags are hints only; coverage is proved by distinct Core TCs.
|
|
358
|
+
if item.get("outcome") == "pass":
|
|
359
|
+
valid, reason = verify_core_result(root, target["feature"], item.get("runtime_evidence"), "passed")
|
|
360
|
+
if not valid:
|
|
361
|
+
evidence_issues.append(f"{evidence['path']}:{evidence['line']}: {reason}")
|
|
362
|
+
continue
|
|
363
|
+
if item.get("outcome") != "fail" or item.get("impact") not in IMPACTS:
|
|
364
|
+
raise ValueError("observation needs pass or fail with a known impact")
|
|
365
|
+
for field in ("trigger", "actual", "improvement", "reproduction"):
|
|
366
|
+
if not isinstance(item.get(field), str) or not item[field].strip():
|
|
367
|
+
evidence_issues.append(f"{evidence['path']}:{evidence['line']}: missing {field}")
|
|
368
|
+
if IMPACTS[item["impact"]] in {"P0", "P1"}:
|
|
369
|
+
authority_node_id = next(e["node_id"] for e in accepted if e["id"] == item["expectation_id"])
|
|
370
|
+
valid, reason = verify_core_result(root, target["feature"], item.get("runtime_evidence"), "failed", authority_node_id)
|
|
371
|
+
if not valid:
|
|
372
|
+
evidence_issues.append(f"{evidence['path']}:{evidence['line']}: {reason}")
|
|
373
|
+
fingerprint = digest(canonical([item["expectation_id"], evidence["path"], evidence["line"], item["impact"], item.get("trigger")]).encode())[:20]
|
|
374
|
+
findings.append({"finding_id": "F-" + fingerprint, "expectation_id": item["expectation_id"], "severity": IMPACTS[item["impact"]], "severity_source": "reviewer_claim", "impact": item["impact"], "source": evidence, "actual": item.get("actual"), "trigger": item.get("trigger"), "reproduction": item.get("reproduction"), "improvement": item.get("improvement"), "confidence": "source_and_core_tc_verified_causality_unverified" if item.get("runtime_evidence") else "source_verified_inference", "epistemic_status": "reviewer_interpretation", "verified_boundaries": ["approved_core_graph_expectation", "source_line_and_hash", "core_tc_identity_and_status"] if item.get("runtime_evidence") else ["approved_core_graph_expectation", "source_line_and_hash"]})
|
|
375
|
+
gaps = sorted(set(required) - covered)
|
|
376
|
+
evidence_issues.extend(coverage_issues)
|
|
377
|
+
if gaps:
|
|
378
|
+
evidence_issues.append("Unverified review coverage: " + ", ".join(gaps))
|
|
379
|
+
if not observations:
|
|
380
|
+
evidence_issues.append("No source-verified observations were supplied")
|
|
381
|
+
previous = {f["finding_id"]: f for f in (prior or {}).get("findings", []) if f.get("state") != "resolved"}
|
|
382
|
+
current_ids = {f["finding_id"] for f in findings}
|
|
383
|
+
for finding in findings:
|
|
384
|
+
finding["state"] = "persisting" if finding["finding_id"] in previous else "new"
|
|
385
|
+
# Resolution requires source change and a separately hash-verified regression artifact.
|
|
386
|
+
for old_id, old in previous.items():
|
|
387
|
+
if old_id not in current_ids:
|
|
388
|
+
old_path = old["source"]["path"]
|
|
389
|
+
if versions.get(old_path) == (prior or {}).get("fixed_versions", {}).get("source", {}).get(old_path):
|
|
390
|
+
evidence_issues.append(f"{old_id}: source unchanged; cannot resolve by removing a report observation")
|
|
391
|
+
else:
|
|
392
|
+
regression = request.get("regression_evidence", {}).get(old_id)
|
|
393
|
+
try:
|
|
394
|
+
if not isinstance(regression, dict) or digest(safe_file(root, regression.get("path")).read_bytes()) != regression.get("sha256"):
|
|
395
|
+
evidence_issues.append(f"{old_id}: missing verified regression evidence")
|
|
396
|
+
else:
|
|
397
|
+
findings.append({**old, "state": "resolved"})
|
|
398
|
+
except (OSError, ValueError, TypeError):
|
|
399
|
+
evidence_issues.append(f"{old_id}: missing verified regression evidence")
|
|
400
|
+
for rel, start_hash in versions.items():
|
|
401
|
+
if digest(safe_file(root, rel).read_bytes()) != start_hash:
|
|
402
|
+
result = response("stale_evidence", chain_id, round_no, questions=[f"Source changed during review: {rel}"])
|
|
403
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
404
|
+
db.commit()
|
|
405
|
+
return result
|
|
406
|
+
for authority in accepted:
|
|
407
|
+
if digest(safe_file(root, authority["authority_path"]).read_bytes()) != authority["authority_sha256"]:
|
|
408
|
+
result = response("stale_evidence", chain_id, round_no, questions=[f"Authority changed during review: {authority['authority_ref']}"])
|
|
409
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
410
|
+
db.commit()
|
|
411
|
+
return result
|
|
412
|
+
if index_discovery.get("generation"):
|
|
413
|
+
current_snapshot = root / ".planning" / "code-index" / "SNAPSHOT.json"
|
|
414
|
+
if not current_snapshot.exists() or read_json(current_snapshot).get("generation") != index_discovery["generation"]:
|
|
415
|
+
result = response("stale_evidence", chain_id, round_no, questions=["Code-index generation changed during review"])
|
|
416
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
417
|
+
db.commit()
|
|
418
|
+
return result
|
|
419
|
+
hard_issues = [issue for issue in evidence_issues if not issue.startswith("Unverified review coverage:") and "coverage obligation" not in issue and "missing Core TC binding" not in issue and "different coverage claim" not in issue and not issue.startswith("Each review surface")]
|
|
420
|
+
if hard_issues:
|
|
421
|
+
result = response("awaiting_human_confirmation", chain_id, round_no, questions=evidence_issues, coverage={"covered": sorted(covered), "gaps": gaps})
|
|
422
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
423
|
+
db.commit()
|
|
424
|
+
return result
|
|
425
|
+
open_findings = [f for f in findings if f["state"] != "resolved"]
|
|
426
|
+
summary = {p: sum(f["severity"] == p for f in open_findings) for p in ("P0", "P1", "P2", "P3")}
|
|
427
|
+
if open_findings:
|
|
428
|
+
# Source and TC hashes establish identity, not that the reviewer's
|
|
429
|
+
# causal story or impact category is true. Until Core has a
|
|
430
|
+
# verifiable independent-review attestation, these are proposals,
|
|
431
|
+
# not completed rounds that can terminate a task after three tries.
|
|
432
|
+
result = response(
|
|
433
|
+
"awaiting_human_confirmation", chain_id, round_no,
|
|
434
|
+
questions=["Independent reviewer identity and business causality/impact require confirmation"],
|
|
435
|
+
proposed_findings=open_findings,
|
|
436
|
+
proposed_risk_summary=summary,
|
|
437
|
+
proposed_improvement_plan=[{"finding_id": f["finding_id"], "severity": f["severity"], "action": f["improvement"]} for f in open_findings],
|
|
438
|
+
coverage={"covered": sorted(covered), "gaps": gaps},
|
|
439
|
+
)
|
|
440
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
441
|
+
db.commit()
|
|
442
|
+
return result
|
|
443
|
+
if not summary["P0"] and not summary["P1"]:
|
|
444
|
+
reasons = [*evidence_issues]
|
|
445
|
+
if suite_has_failures(root, target["feature"]):
|
|
446
|
+
reasons.append("The same Core acceptance run contains failed or blocked TCs; clean cannot be inferred from selected passed cases")
|
|
447
|
+
reasons.append("No Core-verifiable independent reviewer identity or causal semantic attestation is available; clean needs human confirmation")
|
|
448
|
+
result = response("awaiting_human_confirmation", chain_id, round_no, questions=reasons, coverage={"covered": sorted(covered), "gaps": gaps})
|
|
449
|
+
db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
|
|
450
|
+
db.commit()
|
|
451
|
+
return result
|
|
452
|
+
status = "terminated_p0" if round_no == 3 and summary["P0"] else ("findings" if summary["P0"] or summary["P1"] else "clean")
|
|
453
|
+
result = response(status, chain_id, round_no, fixed_versions={"source": versions, "authority": accepted, "code_index_generation": index_discovery.get("generation")}, index_discovery=index_discovery, coverage={"covered": sorted(covered), "gaps": gaps, "index_gap": index_discovery.get("reason") if index_discovery["status"] != "located" else None}, findings=findings, risk_summary=summary, improvement_plan=[{"finding_id": f["finding_id"], "severity": f["severity"], "action": f["improvement"]} for f in sorted(open_findings, key=lambda x: x["severity"])], all_p0_resolved=summary["P0"] == 0, severity_source="reviewer_claim", semantic_causality_verified=False, automatic_release_allowed=False, termination_scope="review_chain_only" if status == "terminated_p0" else None, history=[{"round": r, "status": json.loads(raw)["status"]} for r, raw in db.execute("SELECT round,result_json FROM rounds WHERE chain_id=? ORDER BY round", (chain_id,))])
|
|
454
|
+
db.execute("INSERT INTO rounds(chain_id,round,result_json) VALUES(?,?,?)", (chain_id, round_no, canonical(result)))
|
|
455
|
+
if status == "terminated_p0":
|
|
456
|
+
db.execute("UPDATE chains SET terminal=? WHERE chain_id=?", (status, chain_id))
|
|
457
|
+
db.commit()
|
|
458
|
+
return result
|
|
459
|
+
except Exception:
|
|
460
|
+
db.rollback()
|
|
461
|
+
raise
|
|
462
|
+
finally:
|
|
463
|
+
db.close()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__":
|
|
467
|
+
try:
|
|
468
|
+
project = Path(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
|
|
469
|
+
print(canonical(run(project, json.load(sys.stdin))))
|
|
470
|
+
except (ValueError, OSError, json.JSONDecodeError, sqlite3.Error, StaleEvidence) as error:
|
|
471
|
+
print(canonical({"status": "error", "error": str(error)}))
|
|
472
|
+
sys.exit(2)
|
|
@@ -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
|
|
@@ -525,7 +531,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
525
531
|
validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
526
532
|
validate_domain_modeling_gate(issues, step, data)
|
|
527
533
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
528
|
-
validate_requirement_baseline_gate(issues, step, data)
|
|
534
|
+
validate_requirement_baseline_gate(issues, root, state, step, data)
|
|
529
535
|
validate_api_contract_gate(issues, step, data)
|
|
530
536
|
validate_architecture_feasibility_gate(issues, step, data)
|
|
531
537
|
validate_architecture_consumption_gate(issues, root, state, step, data)
|
|
@@ -641,7 +647,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
641
647
|
)
|
|
642
648
|
end
|
|
643
649
|
|
|
644
|
-
def validate_requirement_baseline_gate(issues, step, data)
|
|
650
|
+
def validate_requirement_baseline_gate(issues, root, state, step, data)
|
|
645
651
|
return unless step["id"] == "requirement-baseline"
|
|
646
652
|
|
|
647
653
|
if data["review_status"] != "passed"
|
|
@@ -657,6 +663,28 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
657
663
|
if data["traceability_status"] != "passed"
|
|
658
664
|
issues << issue("error", "baseline_traceability_not_passed", "Requirement traceability must pass before technical design", step: step["id"], status: data["traceability_status"])
|
|
659
665
|
end
|
|
666
|
+
|
|
667
|
+
graph_path, graph_relative = RequirementGraph.graph_path(root, state["current_feature"])
|
|
668
|
+
unless File.file?(graph_path)
|
|
669
|
+
issues << issue("error", "baseline_governance_graph_missing", "Requirement baseline must bind a Core-owned governance graph", step: step["id"], path: graph_relative)
|
|
670
|
+
return
|
|
671
|
+
end
|
|
672
|
+
begin
|
|
673
|
+
graph, = RequirementGraph.load_graph(root, state["current_feature"])
|
|
674
|
+
graph_errors = RequirementGraph.validate(graph)
|
|
675
|
+
unless graph_errors.empty?
|
|
676
|
+
issues << issue("error", "baseline_governance_graph_invalid", "Requirement baseline is bound to an invalid governance graph", step: step["id"], path: graph_relative, errors: graph_errors)
|
|
677
|
+
end
|
|
678
|
+
if data["graph_revision"].to_i != graph["revision"].to_i
|
|
679
|
+
issues << issue("error", "baseline_graph_revision_mismatch", "Baseline graph_revision does not match governance graph", step: step["id"], expected: graph["revision"], actual: data["graph_revision"])
|
|
680
|
+
end
|
|
681
|
+
actual_hash = Digest::SHA256.file(graph_path).hexdigest
|
|
682
|
+
if data["governance_graph_sha256"].to_s != actual_hash
|
|
683
|
+
issues << issue("error", "baseline_graph_hash_mismatch", "Baseline governance_graph_sha256 does not match governance graph", step: step["id"], expected: actual_hash, actual: data["governance_graph_sha256"])
|
|
684
|
+
end
|
|
685
|
+
rescue ArgumentError, JSON::ParserError => error
|
|
686
|
+
issues << issue("error", "baseline_governance_graph_invalid", error.message, step: step["id"], path: graph_relative)
|
|
687
|
+
end
|
|
660
688
|
end
|
|
661
689
|
|
|
662
690
|
def validate_api_contract_gate(issues, step, data)
|
|
@@ -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:
|
|
@@ -372,7 +369,6 @@ steps:
|
|
|
372
369
|
inputs:
|
|
373
370
|
- ".planning/PROJECT.md"
|
|
374
371
|
- ".planning/navigation/INDEX.md"
|
|
375
|
-
- ".planning/requirements/baseline.md"
|
|
376
372
|
- ".planning/features/"
|
|
377
373
|
- ".planning/milestones/"
|
|
378
374
|
outputs:
|
|
@@ -492,6 +488,14 @@ steps:
|
|
|
492
488
|
- requirement-feasibility-review
|
|
493
489
|
inputs:
|
|
494
490
|
- ".planning/PROJECT.md"
|
|
491
|
+
- ".planning/requirements/{feature}/入口分诊.md"
|
|
492
|
+
- ".planning/requirements/{feature}/决策地图.md"
|
|
493
|
+
- ".planning/requirements/{feature}/领域模型记录.md"
|
|
494
|
+
- ".planning/requirements/{feature}/可实现性审查.md"
|
|
495
|
+
- ".planning/requirements/{feature}/入口分诊.meta.json"
|
|
496
|
+
- ".planning/requirements/{feature}/决策地图.meta.json"
|
|
497
|
+
- ".planning/requirements/{feature}/领域模型记录.meta.json"
|
|
498
|
+
- ".planning/requirements/{feature}/可实现性审查.meta.json"
|
|
495
499
|
- ".planning/navigation/INDEX.md"
|
|
496
500
|
outputs:
|
|
497
501
|
- path: ".planning/requirements/baseline.md"
|
|
@@ -500,6 +504,9 @@ steps:
|
|
|
500
504
|
- path: ".planning/requirements/{feature}/traceability.json"
|
|
501
505
|
type: requirement-traceability
|
|
502
506
|
required: true
|
|
507
|
+
- path: ".planning/requirements/{feature}/governance.json"
|
|
508
|
+
type: requirement-governance-graph
|
|
509
|
+
required: true
|
|
503
510
|
meta_output:
|
|
504
511
|
path: ".planning/requirements/baseline.meta.json"
|
|
505
512
|
fields:
|
|
@@ -517,6 +524,8 @@ steps:
|
|
|
517
524
|
risks: { type: array, required: false }
|
|
518
525
|
key_decisions: { type: array, max: 5, required: false }
|
|
519
526
|
traceability_status: { type: string, required: true }
|
|
527
|
+
graph_revision: { type: integer, required: true }
|
|
528
|
+
governance_graph_sha256: { type: string, required: true }
|
|
520
529
|
checkpoint: CP-1
|
|
521
530
|
|
|
522
531
|
- id: wireframe-proposal
|
|
@@ -610,6 +619,8 @@ steps:
|
|
|
610
619
|
- requirement-baseline
|
|
611
620
|
inputs:
|
|
612
621
|
- ".planning/requirements/baseline.md"
|
|
622
|
+
- ".planning/requirements/{feature}/traceability.json"
|
|
623
|
+
- ".planning/requirements/baseline.meta.json"
|
|
613
624
|
- ".planning/navigation/INDEX.md"
|
|
614
625
|
outputs:
|
|
615
626
|
- path: ".planning/features/{feature}/api-contract.md"
|
|
@@ -638,8 +649,15 @@ steps:
|
|
|
638
649
|
inputs:
|
|
639
650
|
- ".planning/requirements/baseline.md"
|
|
640
651
|
- ".planning/features/{feature}/api-contract.md"
|
|
641
|
-
- ".planning/requirements/{feature}/governance.json"
|
|
642
|
-
|
|
652
|
+
- path: ".planning/requirements/{feature}/governance.json"
|
|
653
|
+
required_at_entry: true
|
|
654
|
+
producer: "requirement-baseline/devflow_requirement_graph"
|
|
655
|
+
validation: requirement-graph
|
|
656
|
+
- path: ".context/static-runtime-context.json"
|
|
657
|
+
required_at_entry: false
|
|
658
|
+
required_at_completion: true
|
|
659
|
+
producer: "devflow_static_runtime_context(action=collect)"
|
|
660
|
+
validation: static-runtime-context
|
|
643
661
|
- ".planning/navigation/INDEX.md"
|
|
644
662
|
outputs:
|
|
645
663
|
- path: ".planning/features/{feature}/architecture-brief.md"
|
|
@@ -671,6 +689,8 @@ steps:
|
|
|
671
689
|
inputs:
|
|
672
690
|
- ".planning/requirements/baseline.md"
|
|
673
691
|
- ".planning/features/{feature}/api-contract.md"
|
|
692
|
+
- ".planning/features/{feature}/architecture-brief.md"
|
|
693
|
+
- ".planning/features/{feature}/architecture-brief.meta.json"
|
|
674
694
|
- ".planning/navigation/INDEX.md"
|
|
675
695
|
outputs:
|
|
676
696
|
- path: ".planning/features/{feature}/UI-SPEC.md"
|
|
@@ -790,6 +810,7 @@ steps:
|
|
|
790
810
|
inputs:
|
|
791
811
|
- ".planning/features/{feature}/api-contract.md"
|
|
792
812
|
- ".planning/features/{feature}/前端技术方案.md"
|
|
813
|
+
- ".planning/features/{feature}/后端技术方案.md"
|
|
793
814
|
- ".planning/features/{feature}/UI-SPEC.md"
|
|
794
815
|
outputs:
|
|
795
816
|
- path: ".planning/features/{feature}/api-supplement-requests.md"
|
|
@@ -818,6 +839,7 @@ steps:
|
|
|
818
839
|
- ".planning/features/{feature}/前端技术方案.md"
|
|
819
840
|
- ".planning/features/{feature}/后端技术方案.md"
|
|
820
841
|
- ".planning/features/{feature}/api-supplement-requests.md"
|
|
842
|
+
- ".planning/features/{feature}/architecture-brief.meta.json"
|
|
821
843
|
outputs:
|
|
822
844
|
- path: ".planning/features/{feature}/integration-report.md"
|
|
823
845
|
type: integration-report
|
|
@@ -877,7 +899,6 @@ steps:
|
|
|
877
899
|
- integration-verify
|
|
878
900
|
inputs:
|
|
879
901
|
- ".planning/requirements/{feature}/governance.json"
|
|
880
|
-
- ".planning/requirements/{feature}/tests.json"
|
|
881
902
|
- ".planning/features/{feature}/integration-report.md"
|
|
882
903
|
- ".planning/features/{feature}/api-contract.md"
|
|
883
904
|
- ".planning/features/{feature}/UI-SPEC.md"
|
|
@@ -9,7 +9,7 @@ description: "Use after API contract and before frontend/backend technical speci
|
|
|
9
9
|
|
|
10
10
|
## 固定流程
|
|
11
11
|
|
|
12
|
-
1. 读取 `devflow_requirement_graph(action="inspect")`、`devflow_static_runtime_context(action="inspect")`、API
|
|
12
|
+
1. 读取 `devflow_requirement_graph(action="inspect")`、`devflow_static_runtime_context(action="inspect")`、API 契约和需求基线。若静态上下文指针不存在或检查结果明确为未生成,先调用 `devflow_static_runtime_context(action="collect")`,再重新 inspect;收集结果必须作为本步骤的输入 revision 记录在 meta 中,不得用手写内容替代。
|
|
13
13
|
2. 调用 `devflow_architecture_feasibility(feature=...)`。保留返回的 graph revision、static snapshot revision、触发码和未决码;不得自行改写等级。
|
|
14
14
|
3. 对命中的工作负载补充 candidate:先用 `devflow_architecture_constraint` 校验,再用 `devflow_requirement_graph(action="propose")` 写入。
|
|
15
15
|
- WSH 使用 `workload_shape`,并连接 `REQ/BEH/API → WSH`,relation 为 `shapes_workload`。
|
|
@@ -731,6 +731,8 @@ Completion gate: baseline and baseline.meta.json are written and contain no unre
|
|
|
731
731
|
|
|
732
732
|
Before claiming baseline readiness, submit `requirement`、`rule`、`boundary`、`slice` and `acceptance` candidates through `devflow_requirement_graph`. Each requirement candidate must cite source IDs and reference behavior/acceptance IDs returned by the writer. Run `devflow_requirement_graph(action="validate")` and record its result in baseline metadata. Do not directly create `governance.json`, allocate IDs, or set candidate status to `confirmed`.
|
|
733
733
|
|
|
734
|
+
The baseline handoff must also call `devflow_requirement_graph(action="inspect")` after all proposals and validation. Record the returned graph `revision` and the SHA-256 of `.planning/requirements/{feature}/governance.json` as `graph_revision` and `governance_graph_sha256` in `baseline.meta.json`. The step is not complete if the Core-owned graph file, revision, or hash is missing.
|
|
735
|
+
|
|
734
736
|
```markdown
|
|
735
737
|
- [ ] 每个原始需求映射到 FP-*/BR-*/BC-* 或 out-of-scope
|
|
736
738
|
- [ ] 追溯表完整,来源→FP 映射可验证
|
package/src/constants.ts
CHANGED