@guanlin4924/devflow-pi 0.1.82 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.82",
3
+ "version": "0.1.83",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -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" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
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
- "allowed_tools" => %w[read grep find glob lsp search write edit apply_patch bash shell exec_command task devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow],
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
@@ -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)
@@ -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
- - ".context/static-runtime-context.json"
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
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.82";
2
+ export const PRODUCT_VERSION = "0.1.83";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5