@guanlin4924/devflow-pi 0.1.82 → 0.1.85

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