@guanlin4924/devflow-pi 0.1.81 → 0.1.83
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 +25 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +7 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +9 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +57 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +4 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +472 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +24 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +28 -4
- 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
|
@@ -3818,6 +3818,31 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
3818
3818
|
},
|
|
3819
3819
|
});
|
|
3820
3820
|
|
|
3821
|
+
pi.registerTool({
|
|
3822
|
+
name: "devflow_review_run",
|
|
3823
|
+
label: "Devflow Fact-Bound Review",
|
|
3824
|
+
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.",
|
|
3825
|
+
parameters: z.object({
|
|
3826
|
+
chain_id: z.string().nullable().optional(),
|
|
3827
|
+
target: z.object({ feature: z.string(), paths: z.array(z.string()), change_ref: z.literal("worktree") }),
|
|
3828
|
+
expectations: z.array(z.object({ id: z.string(), statement: z.string(), authority_ref: z.string() })),
|
|
3829
|
+
review_plan: z.object({ questions: z.array(z.string()), surfaces: z.array(z.string()), required_scenarios: z.array(z.string()) }),
|
|
3830
|
+
observations: z.array(z.unknown()).default([]),
|
|
3831
|
+
coverage_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
3832
|
+
regression_evidence: z.record(z.string(), z.unknown()).optional(),
|
|
3833
|
+
}),
|
|
3834
|
+
approval: "write",
|
|
3835
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3836
|
+
assertRuntimeProjectBoundary(ctx.cwd);
|
|
3837
|
+
return renderResult(await runProcess(
|
|
3838
|
+
["python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_run.py"), ctx.cwd],
|
|
3839
|
+
ctx.cwd,
|
|
3840
|
+
{},
|
|
3841
|
+
JSON.stringify(params),
|
|
3842
|
+
));
|
|
3843
|
+
},
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3821
3846
|
pi.registerTool({
|
|
3822
3847
|
name: "devflow_artifact",
|
|
3823
3848
|
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 }
|
|
@@ -70,7 +70,15 @@ begin
|
|
|
70
70
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
|
|
71
71
|
"workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
|
|
72
72
|
"allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
|
|
73
|
-
|
|
73
|
+
# Core read/write tools required by the architecture feasibility skill are
|
|
74
|
+
# part of the frozen contract. The tool layer still applies per-tool
|
|
75
|
+
# mutation ownership and the step's allowed paths.
|
|
76
|
+
"allowed_tools" => %w[
|
|
77
|
+
read grep find glob lsp search write edit apply_patch bash shell exec_command task
|
|
78
|
+
devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
|
|
79
|
+
devflow_requirement_graph devflow_static_runtime_context
|
|
80
|
+
devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
|
|
81
|
+
],
|
|
74
82
|
"tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
|
|
75
83
|
"allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
|
|
76
84
|
"on_unknown" => "block_and_escalate"
|
|
@@ -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
|
|
@@ -957,7 +957,10 @@ module RequirementGraph
|
|
|
957
957
|
blocking = []
|
|
958
958
|
checkers.each do |id, issues|
|
|
959
959
|
next if DEFERRED_CHECKERS.include?(id)
|
|
960
|
-
|
|
960
|
+
# The derived ID list is an observation, not a failed obligation. A
|
|
961
|
+
# confirmed high-risk node may remain high-risk forever; only its
|
|
962
|
+
# unconfirmed state (checked below) or model-authored risk keys block.
|
|
963
|
+
blocking.concat(id == "risk-derivation-check" ? issues.reject { |entry| entry.start_with?("derived high-risk nodes: ") } : issues)
|
|
961
964
|
end
|
|
962
965
|
Array(graph["nodes"]).each do |node|
|
|
963
966
|
next unless node.is_a?(Hash) && high_risk_by_structure?(node)
|
|
@@ -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)
|
|
@@ -525,7 +525,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
525
525
|
validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
526
526
|
validate_domain_modeling_gate(issues, step, data)
|
|
527
527
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
528
|
-
validate_requirement_baseline_gate(issues, step, data)
|
|
528
|
+
validate_requirement_baseline_gate(issues, root, state, step, data)
|
|
529
529
|
validate_api_contract_gate(issues, step, data)
|
|
530
530
|
validate_architecture_feasibility_gate(issues, step, data)
|
|
531
531
|
validate_architecture_consumption_gate(issues, root, state, step, data)
|
|
@@ -641,7 +641,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
641
641
|
)
|
|
642
642
|
end
|
|
643
643
|
|
|
644
|
-
def validate_requirement_baseline_gate(issues, step, data)
|
|
644
|
+
def validate_requirement_baseline_gate(issues, root, state, step, data)
|
|
645
645
|
return unless step["id"] == "requirement-baseline"
|
|
646
646
|
|
|
647
647
|
if data["review_status"] != "passed"
|
|
@@ -657,6 +657,28 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
657
657
|
if data["traceability_status"] != "passed"
|
|
658
658
|
issues << issue("error", "baseline_traceability_not_passed", "Requirement traceability must pass before technical design", step: step["id"], status: data["traceability_status"])
|
|
659
659
|
end
|
|
660
|
+
|
|
661
|
+
graph_path, graph_relative = RequirementGraph.graph_path(root, state["current_feature"])
|
|
662
|
+
unless File.file?(graph_path)
|
|
663
|
+
issues << issue("error", "baseline_governance_graph_missing", "Requirement baseline must bind a Core-owned governance graph", step: step["id"], path: graph_relative)
|
|
664
|
+
return
|
|
665
|
+
end
|
|
666
|
+
begin
|
|
667
|
+
graph, = RequirementGraph.load_graph(root, state["current_feature"])
|
|
668
|
+
graph_errors = RequirementGraph.validate(graph)
|
|
669
|
+
unless graph_errors.empty?
|
|
670
|
+
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)
|
|
671
|
+
end
|
|
672
|
+
if data["graph_revision"].to_i != graph["revision"].to_i
|
|
673
|
+
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"])
|
|
674
|
+
end
|
|
675
|
+
actual_hash = Digest::SHA256.file(graph_path).hexdigest
|
|
676
|
+
if data["governance_graph_sha256"].to_s != actual_hash
|
|
677
|
+
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"])
|
|
678
|
+
end
|
|
679
|
+
rescue ArgumentError, JSON::ParserError => error
|
|
680
|
+
issues << issue("error", "baseline_governance_graph_invalid", error.message, step: step["id"], path: graph_relative)
|
|
681
|
+
end
|
|
660
682
|
end
|
|
661
683
|
|
|
662
684
|
def validate_api_contract_gate(issues, step, data)
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -372,7 +372,6 @@ steps:
|
|
|
372
372
|
inputs:
|
|
373
373
|
- ".planning/PROJECT.md"
|
|
374
374
|
- ".planning/navigation/INDEX.md"
|
|
375
|
-
- ".planning/requirements/baseline.md"
|
|
376
375
|
- ".planning/features/"
|
|
377
376
|
- ".planning/milestones/"
|
|
378
377
|
outputs:
|
|
@@ -492,6 +491,14 @@ steps:
|
|
|
492
491
|
- requirement-feasibility-review
|
|
493
492
|
inputs:
|
|
494
493
|
- ".planning/PROJECT.md"
|
|
494
|
+
- ".planning/requirements/{feature}/入口分诊.md"
|
|
495
|
+
- ".planning/requirements/{feature}/决策地图.md"
|
|
496
|
+
- ".planning/requirements/{feature}/领域模型记录.md"
|
|
497
|
+
- ".planning/requirements/{feature}/可实现性审查.md"
|
|
498
|
+
- ".planning/requirements/{feature}/入口分诊.meta.json"
|
|
499
|
+
- ".planning/requirements/{feature}/决策地图.meta.json"
|
|
500
|
+
- ".planning/requirements/{feature}/领域模型记录.meta.json"
|
|
501
|
+
- ".planning/requirements/{feature}/可实现性审查.meta.json"
|
|
495
502
|
- ".planning/navigation/INDEX.md"
|
|
496
503
|
outputs:
|
|
497
504
|
- path: ".planning/requirements/baseline.md"
|
|
@@ -500,6 +507,9 @@ steps:
|
|
|
500
507
|
- path: ".planning/requirements/{feature}/traceability.json"
|
|
501
508
|
type: requirement-traceability
|
|
502
509
|
required: true
|
|
510
|
+
- path: ".planning/requirements/{feature}/governance.json"
|
|
511
|
+
type: requirement-governance-graph
|
|
512
|
+
required: true
|
|
503
513
|
meta_output:
|
|
504
514
|
path: ".planning/requirements/baseline.meta.json"
|
|
505
515
|
fields:
|
|
@@ -517,6 +527,8 @@ steps:
|
|
|
517
527
|
risks: { type: array, required: false }
|
|
518
528
|
key_decisions: { type: array, max: 5, required: false }
|
|
519
529
|
traceability_status: { type: string, required: true }
|
|
530
|
+
graph_revision: { type: integer, required: true }
|
|
531
|
+
governance_graph_sha256: { type: string, required: true }
|
|
520
532
|
checkpoint: CP-1
|
|
521
533
|
|
|
522
534
|
- id: wireframe-proposal
|
|
@@ -610,6 +622,8 @@ steps:
|
|
|
610
622
|
- requirement-baseline
|
|
611
623
|
inputs:
|
|
612
624
|
- ".planning/requirements/baseline.md"
|
|
625
|
+
- ".planning/requirements/{feature}/traceability.json"
|
|
626
|
+
- ".planning/requirements/baseline.meta.json"
|
|
613
627
|
- ".planning/navigation/INDEX.md"
|
|
614
628
|
outputs:
|
|
615
629
|
- path: ".planning/features/{feature}/api-contract.md"
|
|
@@ -638,8 +652,15 @@ steps:
|
|
|
638
652
|
inputs:
|
|
639
653
|
- ".planning/requirements/baseline.md"
|
|
640
654
|
- ".planning/features/{feature}/api-contract.md"
|
|
641
|
-
- ".planning/requirements/{feature}/governance.json"
|
|
642
|
-
|
|
655
|
+
- path: ".planning/requirements/{feature}/governance.json"
|
|
656
|
+
required_at_entry: true
|
|
657
|
+
producer: "requirement-baseline/devflow_requirement_graph"
|
|
658
|
+
validation: requirement-graph
|
|
659
|
+
- path: ".context/static-runtime-context.json"
|
|
660
|
+
required_at_entry: false
|
|
661
|
+
required_at_completion: true
|
|
662
|
+
producer: "devflow_static_runtime_context(action=collect)"
|
|
663
|
+
validation: static-runtime-context
|
|
643
664
|
- ".planning/navigation/INDEX.md"
|
|
644
665
|
outputs:
|
|
645
666
|
- path: ".planning/features/{feature}/architecture-brief.md"
|
|
@@ -671,6 +692,8 @@ steps:
|
|
|
671
692
|
inputs:
|
|
672
693
|
- ".planning/requirements/baseline.md"
|
|
673
694
|
- ".planning/features/{feature}/api-contract.md"
|
|
695
|
+
- ".planning/features/{feature}/architecture-brief.md"
|
|
696
|
+
- ".planning/features/{feature}/architecture-brief.meta.json"
|
|
674
697
|
- ".planning/navigation/INDEX.md"
|
|
675
698
|
outputs:
|
|
676
699
|
- path: ".planning/features/{feature}/UI-SPEC.md"
|
|
@@ -790,6 +813,7 @@ steps:
|
|
|
790
813
|
inputs:
|
|
791
814
|
- ".planning/features/{feature}/api-contract.md"
|
|
792
815
|
- ".planning/features/{feature}/前端技术方案.md"
|
|
816
|
+
- ".planning/features/{feature}/后端技术方案.md"
|
|
793
817
|
- ".planning/features/{feature}/UI-SPEC.md"
|
|
794
818
|
outputs:
|
|
795
819
|
- path: ".planning/features/{feature}/api-supplement-requests.md"
|
|
@@ -818,6 +842,7 @@ steps:
|
|
|
818
842
|
- ".planning/features/{feature}/前端技术方案.md"
|
|
819
843
|
- ".planning/features/{feature}/后端技术方案.md"
|
|
820
844
|
- ".planning/features/{feature}/api-supplement-requests.md"
|
|
845
|
+
- ".planning/features/{feature}/architecture-brief.meta.json"
|
|
821
846
|
outputs:
|
|
822
847
|
- path: ".planning/features/{feature}/integration-report.md"
|
|
823
848
|
type: integration-report
|
|
@@ -877,7 +902,6 @@ steps:
|
|
|
877
902
|
- integration-verify
|
|
878
903
|
inputs:
|
|
879
904
|
- ".planning/requirements/{feature}/governance.json"
|
|
880
|
-
- ".planning/requirements/{feature}/tests.json"
|
|
881
905
|
- ".planning/features/{feature}/integration-report.md"
|
|
882
906
|
- ".planning/features/{feature}/api-contract.md"
|
|
883
907
|
- ".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