@guanlin4924/devflow-pi 0.1.24 → 0.1.25

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/README.md CHANGED
@@ -130,6 +130,7 @@ bash .skills/install.sh --apply --force-config
130
130
  - `TC-*` 的步骤和 oracle 在代码出现前冻结;CP-3 在同一次确认中先校验候选绑定,再冻结 TC 并固定绑定哈希。需求或契约变化会使它 stale,不能继续以旧测试集宣称通过。
131
131
  - `tdd_required` 任务必须先由 Core 记录准备快照,再记录 RED(命中预期失败)、同一目标的 GREEN 和回归;缺失或环境失败不算完成。
132
132
  - API、跨数据域、边界、迁移、工作负载与架构风险会派生相应的 `api`、`permission`、`boundary`、`migration`、`performance`、`resilience` TC 义务。
133
+ - 每个 TC 还要声明证明策略:`replay` 用组件/页面/API/集成的稳定回放;`causal` 用已定位的首个偏差加 HAR、控制台、DOM 快照、截图或日志等受控文件证明,Core 会记录文件哈希;`observation` 用于尚不可复现、也未能证明根因的问题,只能保持阻塞并带观测计划。后两者都不能被“AI 已复现”或 Markdown 自述放行。
133
134
  - 代码完成后,`workflow-run-acceptance-tests` 只能运行绑定到冻结 TC、且已在初始化 `.planning/toolchain.md` 发现的测试运行器命令。任何失败、未执行、人工待执行或环境阻塞用例都会阻止 CP-4。
134
135
  - 验收不是代码完成后的临时手工检查。它是需求基线在编码前派生并冻结的完整测试集,覆盖主流程、异常、边界、权限、迁移、兼容、韧性和工作负载义务;代码只能补充执行适配信息,不能改变测试意图。
135
136
  - 验收失败不会创建另一套人工台账,而是进入 `acceptance-remediation`:诊断 → 修复 → code-index 刷新 → 原冻结 `TC-*` 重跑。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -20,6 +20,8 @@ from test_command_policy import validate_test_command
20
20
 
21
21
  FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
22
22
  AUTOMATION_MODES = {"automated", "manual", "blocked"}
23
+ EVIDENCE_STRATEGIES = {"replay", "causal", "observation"}
24
+ MAX_EVIDENCE_FILE_BYTES = 25 * 1024 * 1024
23
25
 
24
26
 
25
27
  def utc_now() -> str:
@@ -142,6 +144,38 @@ def file_fingerprint(path: Path) -> str | None:
142
144
  return digest(path.read_bytes()) if path.is_file() else None
143
145
 
144
146
 
147
+ def evidence_artifacts(root: Path, value: Any) -> tuple[list[dict[str, Any]], list[str]]:
148
+ """Hash causal evidence from bounded project-relative files only."""
149
+ if not isinstance(value, list) or not value:
150
+ return [], ["causal verification requires non-empty evidence_files"]
151
+ root_real = root.resolve()
152
+ artifacts: list[dict[str, Any]] = []
153
+ errors: list[str] = []
154
+ for raw in value:
155
+ if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
156
+ errors.append("evidence_files entries must be non-empty relative paths")
157
+ continue
158
+ candidate = Path(raw)
159
+ if candidate.is_absolute() or ".." in candidate.parts:
160
+ errors.append(f"evidence file escapes project root: {raw}")
161
+ continue
162
+ resolved = (root_real / candidate).resolve()
163
+ try:
164
+ relative = resolved.relative_to(root_real)
165
+ except ValueError:
166
+ errors.append(f"evidence file escapes project root: {raw}")
167
+ continue
168
+ if not resolved.is_file():
169
+ errors.append(f"causal evidence file is missing: {raw}")
170
+ continue
171
+ size = resolved.stat().st_size
172
+ if size > MAX_EVIDENCE_FILE_BYTES:
173
+ errors.append(f"causal evidence file exceeds {MAX_EVIDENCE_FILE_BYTES} bytes: {raw}")
174
+ continue
175
+ artifacts.append({"path": relative.as_posix(), "sha256": digest(resolved.read_bytes()), "bytes": size})
176
+ return artifacts, errors
177
+
178
+
145
179
  def run_http_case(source: dict[str, Any], adapter: dict[str, Any]) -> tuple[bool, int, str]:
146
180
  execution = source["execution"]
147
181
  request_spec = execution["request"]
@@ -248,6 +282,11 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
248
282
  if source is None:
249
283
  errors.append(f"unknown TC reference: {tc_id}")
250
284
  continue
285
+ strategy = source.get("evidence_strategy")
286
+ if strategy is not None and strategy not in EVIDENCE_STRATEGIES:
287
+ errors.append(f"{label}.source evidence_strategy is invalid")
288
+ if strategy == "observation" and source.get("automation_mode") != "blocked":
289
+ errors.append(f"{label}.observation source must be blocked")
251
290
  mode = entry.get("mode")
252
291
  if mode not in AUTOMATION_MODES:
253
292
  errors.append(f"{label}.mode is invalid")
@@ -278,6 +317,9 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
278
317
  errors.append(f"{label}.adapter.setup: {setup_error}")
279
318
  else:
280
319
  errors.append(f"{label}.adapter kind must be command or http")
320
+ if strategy == "causal":
321
+ _artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
322
+ errors.extend(f"{label}.{error}" for error in artifact_errors)
281
323
  elif mode == "manual":
282
324
  if not isinstance(entry.get("owner"), str) or not entry["owner"].strip() or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
283
325
  errors.append(f"{label}.manual requires owner and reason")
@@ -303,6 +345,7 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
303
345
  for entry in bindings["cases"]:
304
346
  tc_id = entry["tc_id"]
305
347
  mode = entry["mode"]
348
+ strategy = cases[tc_id].get("evidence_strategy")
306
349
  if mode == "automated":
307
350
  started_at = utc_now()
308
351
  adapter = entry["adapter"]
@@ -326,6 +369,13 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
326
369
  result = {"tc_id": tc_id, "status": "passed" if passed else "failed", "mode": mode, "adapter_kind": "http",
327
370
  "adapter_fingerprint": digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
328
371
  "finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
372
+ if strategy == "causal":
373
+ artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
374
+ if artifact_errors:
375
+ result["status"] = "failed"
376
+ result["evidence_error"] = "; ".join(artifact_errors)
377
+ else:
378
+ result["evidence_artifacts"] = artifacts
329
379
  results.append(result)
330
380
  elif mode == "manual":
331
381
  results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "reason": entry["reason"],
@@ -554,6 +554,8 @@ def tc_candidate(api: dict[str, Any], graph: dict[str, Any], key: str, obligatio
554
554
  "steps": ["发送 execution.request 中定义的 HTTP 请求"],
555
555
  "oracle": oracle,
556
556
  "automation_mode": "automated",
557
+ "evidence_strategy": "replay",
558
+ "replay_scope": "api",
557
559
  "api_refs": [api["id"]],
558
560
  "execution": execution,
559
561
  }
@@ -96,13 +96,29 @@ module ArchitectureBrief
96
96
  assessment["triggers"].each { |trigger| lines << "- `#{trigger["code"]}` — #{trigger["node_id"]}" }
97
97
  end
98
98
 
99
- lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 执行 | 操作 | 扇出 | 数据影响 | 外部调用 |", "|---|---|---|---|---|---|---|"])
99
+ lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 类型 | 一致性 | 执行 | 操作 | 扇出 | 数据影响 |", "|---|---|---|---|---|---|---|---|"])
100
100
  if workload_shapes.empty?
101
- lines << "| — | — | — | — | — | — | — |"
101
+ lines << "| — | — | — | — | — | — | — | — |"
102
102
  else
103
103
  workload_shapes.each do |node|
104
104
  facts = node.fetch("data", {}).fetch("facts", {})
105
- lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} | #{text(Array(facts["external_calls"]).join(", "))} |"
105
+ lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["archetype"] || "legacy")} | #{text(facts["consistency"] || "—")} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} |"
106
+ end
107
+ end
108
+
109
+ metrics = Array(graph["nodes"]).select { |node| node["kind"] == "metric" }
110
+ read_models = Array(graph["nodes"]).select { |node| node["kind"] == "read_model" }
111
+ lines.concat(["", "## 统计指标与读模型", "", "| 节点 | 状态 | 定义/服务对象 | 时间与修正 |", "|---|---|---|---|"])
112
+ if metrics.empty? && read_models.empty?
113
+ lines << "| — | — | — | — |"
114
+ else
115
+ metrics.each do |node|
116
+ data = node.fetch("data", {})
117
+ lines << "| #{node["id"]} | #{node["status"]} | 指标:#{text(data["name"])};粒度:#{text(data["grain"])} | #{text(data["time_basis"])} / #{text(data["timezone"])};#{text(data["correction_policy"])} |"
118
+ end
119
+ read_models.each do |node|
120
+ data = node.fetch("data", {})
121
+ lines << "| #{node["id"]} | #{node["status"]} | 读模型:#{text(data["projection_mode"])};服务:#{text(Array(data["serves"]).join(", "))} | 重建:#{text(data.dig("rebuild", "method"))};迟到数据:#{text(data["late_data_policy"])} |"
106
122
  end
107
123
  end
108
124
 
@@ -13,6 +13,10 @@ module ArchitectureConstraints
13
13
  OPERATIONS = %w[read write batch_read batch_write].freeze
14
14
  FANOUTS = %w[none bounded unbounded unknown].freeze
15
15
  DATA_EFFECTS = %w[none update append_only migration unknown].freeze
16
+ ARCHETYPES = %w[transactional_command operational_query analytics_query analytics_projection batch_processing external_integration].freeze
17
+ DATA_ROLES = %w[source_of_truth derived_read_model external_projection].freeze
18
+ CONSISTENCIES = %w[strong read_your_write bounded_staleness eventual unknown].freeze
19
+ EVENT_TIMES = %w[required not_applicable unknown].freeze
16
20
  METRICS = %w[peak_qps data_growth p95_latency rpo rto].freeze
17
21
  SOURCE_STATUSES = %w[measured contracted approved_assumption unknown].freeze
18
22
  EVIDENCE_KINDS = %w[source_file cp_record external_measurement].freeze
@@ -52,6 +56,26 @@ module ArchitectureConstraints
52
56
  errors_for_enum(errors, facts, "data_effect", DATA_EFFECTS)
53
57
  external_calls = facts["external_calls"]
54
58
  errors << "external_calls must be an array" unless external_calls.nil? || external_calls.is_a?(Array)
59
+ # `archetype` is optional for pre-v5 graphs. Once supplied it is a
60
+ # contract, not prose: analytics shapes must say what they serve and how
61
+ # their time semantics behave.
62
+ if facts.key?("archetype")
63
+ errors_for_enum(errors, facts, "archetype", ARCHETYPES)
64
+ errors_for_enum(errors, facts, "data_role", DATA_ROLES)
65
+ errors_for_enum(errors, facts, "consistency", CONSISTENCIES)
66
+ time = facts["time_semantics"]
67
+ errors << "time_semantics must be an object" unless time.is_a?(Hash)
68
+ errors_for_enum(errors, time || {}, "event_time", EVENT_TIMES)
69
+ errors << "time_semantics.timezone is required" unless present?(time && time["timezone"])
70
+ if %w[analytics_query analytics_projection].include?(facts["archetype"])
71
+ refs = shape["metric_refs"]
72
+ errors << "analytics workloads require non-empty metric_refs" unless refs.is_a?(Array) && !refs.empty?
73
+ end
74
+ if facts["archetype"] == "analytics_projection"
75
+ refs = shape["read_model_refs"]
76
+ errors << "analytics projections require non-empty read_model_refs" unless refs.is_a?(Array) && !refs.empty?
77
+ end
78
+ end
55
79
  source_refs = shape["source_refs"]
56
80
  errors << "source_refs must be a non-empty array" unless source_refs.is_a?(Array) && !source_refs.empty?
57
81
  Array(source_refs).each { |ref| errors << "source_refs must reference REQ/BEH/API" unless reference?(ref, %w[REQ BEH API]) }
@@ -78,6 +78,21 @@ module ArchitectureFeasibility
78
78
  data = node.fetch("data")
79
79
  if node["kind"] == "workload_shape"
80
80
  facts = data.fetch("facts")
81
+ case facts["archetype"]
82
+ when "analytics_query"
83
+ level = promote(level, "full")
84
+ triggers << { "code" => "ARC_ANALYTICS_QUERY", "node_id" => node["id"] }
85
+ when "analytics_projection"
86
+ level = promote(level, "full")
87
+ triggers << { "code" => "ARC_ANALYTICS_PROJECTION", "node_id" => node["id"] }
88
+ when "transactional_command"
89
+ level = promote(level, "lightweight")
90
+ triggers << { "code" => "ARC_TRANSACTIONAL_COMMAND", "node_id" => node["id"] }
91
+ end
92
+ if facts["consistency"] == "unknown"
93
+ level = promote(level, "lightweight")
94
+ unresolved << { "code" => "ARC_CONSISTENCY_UNKNOWN", "node_id" => node["id"] }
95
+ end
81
96
  case facts["data_effect"]
82
97
  when "migration"
83
98
  level = promote(level, "full")
@@ -121,10 +136,19 @@ module ArchitectureFeasibility
121
136
  end
122
137
  end
123
138
 
139
+ Array(graph["nodes"]).each do |node|
140
+ next unless node.is_a?(Hash) && node["kind"] == "read_model" && node["status"] == "confirmed"
141
+ data = node.fetch("data", {})
142
+ if data.dig("rebuild", "method") == "unknown" || data["late_data_policy"] == "unknown"
143
+ level = promote(level, "lightweight")
144
+ unresolved << { "code" => "ARC_READ_MODEL_RECOVERY_UNKNOWN", "node_id" => node["id"] }
145
+ end
146
+ end
147
+
124
148
  unless static_context["available"]
125
149
  unresolved << { "code" => static_context["code"], "detail" => static_context["detail"] }.compact
126
150
  end
127
- workload_errors = graph_errors.grep(/\A(?:WSH|WLC)-/)
151
+ workload_errors = graph_errors.grep(/\A(?:WSH|WLC|METRIC|RM)-/)
128
152
  unresolved << { "code" => "ARC_WORKLOAD_GRAPH_INVALID", "count" => workload_errors.length } if workload_errors.any?
129
153
 
130
154
  # P2 deliberately uses no numeric threshold. A numeric constraint is a
@@ -13,16 +13,17 @@ require_relative "control_io"
13
13
  require_relative "architecture_constraints"
14
14
 
15
15
  module RequirementGraph
16
- # v4 adds derived TC verification nodes. Existing v1-v3
16
+ # v5 adds workload archetypes plus METRIC/RM nodes. Existing v1-v4
17
17
  # graphs are migrated in memory and re-written only by this Core writer.
18
- SCHEMA_VERSION = 4
19
- SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, SCHEMA_VERSION].freeze
18
+ SCHEMA_VERSION = 5
19
+ SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, 4, SCHEMA_VERSION].freeze
20
20
  ROOT = ".planning/requirements"
21
21
  KINDS = {
22
22
  "source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
23
23
  "behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
24
24
  "slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
25
- "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "test_case" => "TC"
25
+ "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "metric" => "METRIC",
26
+ "read_model" => "RM", "test_case" => "TC"
26
27
  }.freeze
27
28
  VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance tests traceability].freeze
28
29
  FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
@@ -176,7 +177,7 @@ module RequirementGraph
176
177
  raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
177
178
  data = normalize_data(delta["data"])
178
179
  node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
179
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
180
+ candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
180
181
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
181
182
  set_node_fingerprint!(node)
182
183
  graph["nodes"] << node
@@ -193,12 +194,12 @@ module RequirementGraph
193
194
  id = delta["id"]
194
195
  node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
195
196
  raise ArgumentError, "update requires a known node id" unless node
196
- if (%w[workload_shape workload_constraint test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
197
- raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
197
+ if (%w[workload_shape workload_constraint metric read_model test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
198
+ raise ArgumentError, "confirmed workload, metric/read-model, and architecture decision nodes are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
198
199
  end
199
200
  data = normalize_data(delta["data"])
200
201
  node["data"] = node.fetch("data", {}).merge(data)
201
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
202
+ candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
202
203
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
203
204
  set_node_fingerprint!(node)
204
205
  clear_stale_for!(graph, id)
@@ -221,10 +222,10 @@ module RequirementGraph
221
222
  unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
222
223
  raise ArgumentError, "confirm requires cp_record or evidence_ref"
223
224
  end
224
- if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
225
- raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
225
+ if (%w[workload_shape workload_constraint metric read_model].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
226
+ raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog; metric/read-model candidates use the same dialog"
226
227
  end
227
- confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
228
+ confirmation_errors = workload_confirmation_errors(graph, node, evidence) + metric_confirmation_errors(graph, node) + read_model_confirmation_errors(graph, node) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
228
229
  raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
229
230
  node["status"] = "confirmed"
230
231
  node["confirmation"] = { "origin" => "core", "evidence" => evidence }
@@ -271,8 +272,14 @@ module RequirementGraph
271
272
  end
272
273
  end
273
274
 
274
- TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance].freeze
275
+ TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance analytics freshness reconciliation].freeze
275
276
  TEST_AUTOMATION_MODES = %w[automated manual blocked].freeze
277
+ # A verification strategy says what would constitute trustworthy evidence.
278
+ # It is intentionally separate from automation_mode: a page test may be
279
+ # replayable yet initially manual, while an intermittent incident may be
280
+ # causally proven without a deterministic replay.
281
+ TEST_EVIDENCE_STRATEGIES = %w[replay causal observation].freeze
282
+ REPLAY_SCOPES = %w[component page api integration].freeze
276
283
 
277
284
  # TC nodes are derived verification intent. They deliberately store only
278
285
  # externally observable setup, operation and oracle; implementation paths,
@@ -282,7 +289,7 @@ module RequirementGraph
282
289
 
283
290
  data = node["data"].is_a?(Hash) ? node["data"] : {}
284
291
  errors = []
285
- %w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode].each do |field|
292
+ %w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
286
293
  errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
287
294
  end
288
295
  errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
@@ -291,6 +298,25 @@ module RequirementGraph
291
298
  errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
292
299
  errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
293
300
  errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
301
+ errors << "#{node["id"]}: invalid evidence_strategy" unless TEST_EVIDENCE_STRATEGIES.include?(data["evidence_strategy"])
302
+ case data["evidence_strategy"]
303
+ when "replay"
304
+ errors << "#{node["id"]}: replay tests require replay_scope" unless REPLAY_SCOPES.include?(data["replay_scope"])
305
+ when "causal"
306
+ refs = data["causal_evidence_refs"]
307
+ errors << "#{node["id"]}: causal tests require non-empty causal_evidence_refs" unless refs.is_a?(Array) && !refs.empty?
308
+ Array(refs).each do |ref|
309
+ errors << "#{node["id"]}: causal evidence ref #{ref} must reference SRC/CODE" unless refs_known?(graph, [ref], %w[SRC CODE])
310
+ end
311
+ errors << "#{node["id"]}: causal tests require first_divergence" unless present?(data["first_divergence"])
312
+ when "observation"
313
+ plan = data["observation_plan"]
314
+ errors << "#{node["id"]}: observation tests require observation_plan" unless plan.is_a?(Hash)
315
+ %w[signal owner window alert_condition].each do |field|
316
+ errors << "#{node["id"]}: observation_plan requires #{field}" unless plan.is_a?(Hash) && present?(plan[field])
317
+ end
318
+ errors << "#{node["id"]}: observation tests must remain blocked until Core-observed evidence exists" unless data["automation_mode"] == "blocked"
319
+ end
294
320
  if data.key?("test_obligation_refs")
295
321
  errors << "#{node["id"]}: test_obligation_refs must be a non-empty string array when present" unless data["test_obligation_refs"].is_a?(Array) && !data["test_obligation_refs"].empty? && data["test_obligation_refs"].all? { |ref| ref.is_a?(String) && ref.match?(/\AOBL-[A-Z0-9-]+\z/) }
296
322
  end
@@ -322,7 +348,9 @@ module RequirementGraph
322
348
  {
323
349
  "api_refs" => %w[API],
324
350
  "workload_refs" => %w[WSH WLC],
325
- "risk_refs" => %w[RISK]
351
+ "risk_refs" => %w[RISK],
352
+ "metric_refs" => %w[METRIC],
353
+ "read_model_refs" => %w[RM]
326
354
  }.each do |field, prefixes|
327
355
  next unless data.key?(field)
328
356
 
@@ -504,6 +532,16 @@ module RequirementGraph
504
532
  errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
505
533
  errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
506
534
  end
535
+ { "metric_refs" => %w[METRIC], "read_model_refs" => %w[RM] }.each do |field, prefixes|
536
+ next unless data.key?(field)
537
+ errors << "#{node["id"]}: #{field} must be a non-empty array when present" unless data[field].is_a?(Array) && !data[field].empty?
538
+ Array(data[field]).each do |ref|
539
+ errors << "#{node["id"]}: #{field} #{ref} must exist in the graph" unless refs_known?(graph, [ref], prefixes)
540
+ relation = field == "metric_refs" ? "defines_metric" : "uses_read_model"
541
+ errors << "#{node["id"]}: #{field} #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
542
+ errors << "#{node["id"]}: #{field} #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
543
+ end
544
+ end
507
545
  else
508
546
  workload_ref = data["workload_ref"]
509
547
  errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
@@ -528,6 +566,74 @@ module RequirementGraph
528
566
  errors
529
567
  end
530
568
 
569
+ # A metric is a business contract, not a free-text label on a dashboard.
570
+ # It deliberately has no status/ID input: those remain Core-owned.
571
+ def metric_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
572
+ return [] unless node["kind"] == "metric"
573
+
574
+ data = node.fetch("data", {})
575
+ errors = []
576
+ %w[name grain measure inclusion_rule_refs exclusion_rule_refs dimensions time_basis timezone correction_policy owner].each do |field|
577
+ errors << "#{node["id"]}: metric missing #{field}" unless present?(data[field])
578
+ end
579
+ %w[inclusion_rule_refs exclusion_rule_refs dimensions].each do |field|
580
+ errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
581
+ end
582
+ Array(data["inclusion_rule_refs"]) .concat(Array(data["exclusion_rule_refs"])).each do |ref|
583
+ errors << "#{node["id"]}: metric rule #{ref} must reference BR" unless refs_known?(graph, [ref], %w[BR])
584
+ errors << "#{node["id"]}: metric rule #{ref} must link with defines_metric" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_metric")
585
+ errors << "#{node["id"]}: metric rule #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
586
+ end
587
+ errors.uniq
588
+ end
589
+
590
+ def metric_confirmation_errors(graph, node)
591
+ metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
592
+ end
593
+
594
+ READ_MODEL_MODES = %w[direct_query materialized_view scheduled_aggregate event_projection warehouse].freeze
595
+ REBUILD_METHODS = %w[replay_from_fact full_recompute unknown].freeze
596
+ LATE_DATA_POLICIES = %w[recompute_window correction_event reject unknown].freeze
597
+
598
+ def read_model_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
599
+ return [] unless node["kind"] == "read_model"
600
+
601
+ data = node.fetch("data", {})
602
+ errors = []
603
+ %w[source_of_truth_refs serves metric_contract_refs projection_mode rebuild late_data_policy reconciliation owner].each do |field|
604
+ errors << "#{node["id"]}: read model missing #{field}" unless present?(data[field])
605
+ end
606
+ %w[source_of_truth_refs serves metric_contract_refs].each do |field|
607
+ errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
608
+ end
609
+ errors << "#{node["id"]}: invalid projection_mode" unless READ_MODEL_MODES.include?(data["projection_mode"])
610
+ errors << "#{node["id"]}: rebuild must be an object" unless data["rebuild"].is_a?(Hash)
611
+ errors << "#{node["id"]}: invalid rebuild.method" unless REBUILD_METHODS.include?(data.dig("rebuild", "method"))
612
+ errors << "#{node["id"]}: invalid late_data_policy" unless LATE_DATA_POLICIES.include?(data["late_data_policy"])
613
+ errors << "#{node["id"]}: reconciliation must be an object" unless data["reconciliation"].is_a?(Hash)
614
+ %w[method frequency].each { |field| errors << "#{node["id"]}: reconciliation missing #{field}" unless present?(data.dig("reconciliation", field)) }
615
+ Array(data["source_of_truth_refs"]).each do |ref|
616
+ errors << "#{node["id"]}: source #{ref} must reference BEH/OBJECT" unless refs_known?(graph, [ref], %w[BEH OBJECT])
617
+ errors << "#{node["id"]}: source #{ref} must link with derives_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "derives_read_model")
618
+ errors << "#{node["id"]}: source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
619
+ end
620
+ Array(data["serves"]).each do |ref|
621
+ errors << "#{node["id"]}: serves #{ref} must reference API" unless refs_known?(graph, [ref], %w[API])
622
+ errors << "#{node["id"]}: serves #{ref} must link with serves_query" if require_edges && !incoming_edge?(graph, node["id"], ref, "serves_query")
623
+ errors << "#{node["id"]}: serves #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
624
+ end
625
+ Array(data["metric_contract_refs"]).each do |ref|
626
+ errors << "#{node["id"]}: metric #{ref} must reference METRIC" unless refs_known?(graph, [ref], %w[METRIC])
627
+ errors << "#{node["id"]}: metric #{ref} must link with defines_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_read_model")
628
+ errors << "#{node["id"]}: metric #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
629
+ end
630
+ errors.uniq
631
+ end
632
+
633
+ def read_model_confirmation_errors(graph, node)
634
+ read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
635
+ end
636
+
531
637
  def architecture_option_decision?(node)
532
638
  node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
533
639
  end
@@ -666,6 +772,14 @@ module RequirementGraph
666
772
  results["requirement-graph-check"].concat(
667
773
  workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
668
774
  )
775
+ when "metric"
776
+ results["requirement-graph-check"].concat(
777
+ metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
778
+ )
779
+ when "read_model"
780
+ results["requirement-graph-check"].concat(
781
+ read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
782
+ )
669
783
  when "acceptance"
670
784
  required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
671
785
  types = Array(node.dig("data", "verification_types"))
@@ -705,6 +819,13 @@ module RequirementGraph
705
819
  if node["kind"] == "workload_shape" && node.dig("data", "facts", "data_effect") == "migration"
706
820
  results["test-obligation-check"] << "#{node["id"]}: migration workload requires a migration TC with workload_refs" unless covered_by.call("migration", "workload_refs", node["id"])
707
821
  end
822
+ if node["kind"] == "workload_shape" && %w[analytics_query analytics_projection].include?(node.dig("data", "facts", "archetype"))
823
+ results["test-obligation-check"] << "#{node["id"]}: analytics workload requires an analytics TC with workload_refs" unless covered_by.call("analytics", "workload_refs", node["id"])
824
+ end
825
+ when "metric"
826
+ results["test-obligation-check"] << "#{node["id"]}: metric requires a reconciliation TC with metric_refs" unless covered_by.call("reconciliation", "metric_refs", node["id"])
827
+ when "read_model"
828
+ results["test-obligation-check"] << "#{node["id"]}: read model requires a freshness TC with read_model_refs" unless covered_by.call("freshness", "read_model_refs", node["id"])
708
829
  when "risk"
709
830
  if architecture_option_risk?(node)
710
831
  results["test-obligation-check"] << "#{node["id"]}: architecture risk requires a resilience TC with risk_refs" unless covered_by.call("resilience", "risk_refs", node["id"])
@@ -760,7 +881,7 @@ module RequirementGraph
760
881
  when "evidence" then %w[source code]
761
882
  when "definitions" then %w[term object rule boundary]
762
883
  when "behavior-graph" then %w[behavior]
763
- when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint test_case]
884
+ when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint metric read_model test_case]
764
885
  when "slices" then %w[slice]
765
886
  when "acceptance" then %w[acceptance risk decision]
766
887
  when "tests" then %w[test_case]
@@ -43,9 +43,10 @@ The meta file must also set `traceability_status: "passed"` only after every API
43
43
  1. **只定义接口契约,不谈实现细节** — 不写数据表结构、不写具体代码、不写业务流程内部逻辑
44
44
  2. **分叉后不变更** — 一旦确定,Track A/B 都基于此契约工作。变更走 `api-supplement` 异常路径
45
45
  3. **接口签名必须完整** — 包括 endpoint、method、请求参数(含类型约束)、响应结构(含状态码)、错误契约与行为语义(过滤、排序、权限、幂等/兼容规则)
46
- 4. **数据模型共享** — 核心业务实体定义在这里,前后端共用一个数据模型
47
- 5. **切片对齐** — 若 baseline 包含 `SL-*`,先读取 `skill://workflow-pipeline/references/slice-safety-guide.md`,只为当前执行切片定义 API。后置切片接口不得提前进入契约。
48
- 6. **既有接口优先查事实索引** — 当 `.planning/code-index/` 存在时,先用 Symbol/Relation 定位候选接口、调用方和处理器,再读取源码证据;缺失或过期时再按 navigation 和 `rg` 兜底。
46
+ 4. **负载职责明确** — 事务命令、运营查询和统计查询不得在同一个接口语义中混写;统计查询必须引用已确认的 `METRIC-*`,并在响应中约定 `as_of`/新鲜度语义或明确强一致依据。
47
+ 5. **数据模型共享** — 核心业务实体定义在这里,前后端共用一个数据模型
48
+ 6. **切片对齐** — 若 baseline 包含 `SL-*`,先读取 `skill://workflow-pipeline/references/slice-safety-guide.md`,只为当前执行切片定义 API。后置切片接口不得提前进入契约。
49
+ 7. **既有接口优先查事实索引** — 当 `.planning/code-index/` 存在时,先用 Symbol/Relation 定位候选接口、调用方和处理器,再读取源码证据;缺失或过期时再按 navigation 和 `rg` 兜底。
49
50
 
50
51
  ## 可测试 API 契约(强制)
51
52
 
@@ -15,6 +15,8 @@ description: "Use after API contract and before frontend/backend technical speci
15
15
  - WSH 使用 `workload_shape`,并连接 `REQ/BEH/API → WSH`,relation 为 `shapes_workload`。
16
16
  - WLC 使用 `workload_constraint`,并连接 `BEH/SL/API → WLC`,relation 为 `constrains_workload`。
17
17
  - `unknown` 只能写缺口、责任人、截止 checkpoint;不能填 target、evidence 或 assumption。
18
+ - 每个新增/变更的 API、BEH、Job 至少归类为 `transactional_command`、`operational_query`、`analytics_query`、`analytics_projection`、`batch_processing` 或 `external_integration`;历史节点没有 `archetype` 时按 legacy 处理,不倒逼补录。
19
+ - `analytics_query` 必须关联 `METRIC-*`;`analytics_projection` 还必须关联 `RM-*`。先提交指标口径(粒度、纳入/排除规则、时间字段/时区、修正规则、owner),再提交读模型(事实源、服务 API、投影/刷新方式、重建、迟到数据和对账)。
18
20
  4. 对 `lightweight` 或 `full` 且存在实质取舍的结果,提交 `decision_type: architecture_option_comparison` 的 `DECISION` candidate:
19
21
  - `subject_refs` 只引用 WSH/WLC;WSH 用 `informs_decision` 连到 DECISION,WLC 用 `constrains_decision` 连到 DECISION。
20
22
  - 每个 option 使用 `option_key`,并包含 `mechanism`、非空 `fact_refs`、非空 `tradeoffs`。
@@ -31,6 +33,8 @@ description: "Use after API contract and before frontend/backend technical speci
31
33
  - `assessment_status: incomplete`、`comparison_status: missing` 或 `awaiting_*` 必须原样交给下游技术方案,不能写成“评估通过”。
32
34
  - 已确认 WSH/WLC/架构 DECISION/RISK 不可原地更新;新建 candidate、重新连边,并让 stale 传播要求消费者复核。
33
35
  - 不替用户选择方案、接受残余风险或伪造 CP 记录。
36
+ - 业务事实表是唯一可审计真相源;`RM-*` 是可重建的派生读模型,不能被业务命令当作事实源回写。
37
+ - 不因“统计”自动要求拆库、消息队列或数仓。`direct_query`、物化视图、定时汇总、事件投影和数仓必须根据 WSH/WLC 给出有实质差异的比较与升级条件。
34
38
 
35
39
  ## 下游交接
36
40
 
@@ -48,6 +48,8 @@ allowed-tools:
48
48
 
49
49
  读取 `.planning/requirements/{feature}/traceability.json`。每个设计章节必须列 `覆盖 REQ: REQ-*`;定时任务、批处理、导入和通知至少拆为“编排(扫描/触发/锁/重试)”与“业务算法(判定/计算/写入)”两个可独立验证的小节。不得把遍历全部活跃组织、发送通知、文件解析等编排事实藏在算法描述中。
50
50
 
51
+ 当架构简报包含 `analytics_query`、`analytics_projection`、`METRIC-*` 或 `RM-*` 时,后端方案额外必须分别写明:业务事实源;指标粒度/纳入排除/时区与修正;读模型或直接聚合路径;索引/扫描范围;新鲜度;watermark/迟到数据;重建与对账。不得把统计 SQL 混进事务命令,或以“后续补统计”跳过这些字段。
52
+
51
53
  根据需求基线和项目现有代码,生成聚焦于影响分析的后端技术方案。
52
54
 
53
55
  ## 核心原则
@@ -39,11 +39,15 @@ description: "Use after integration verification and before change planning to d
39
39
  "preconditions": ["身份、数据和环境前置条件"],
40
40
  "steps": ["测试操作"],
41
41
  "oracle": "可观察的预期结果",
42
- "automation_mode": "automated|manual|blocked"
42
+ "automation_mode": "automated|manual|blocked",
43
+ "evidence_strategy": "replay|causal|observation"
43
44
  }
44
45
  ```
45
46
 
46
47
  - `manual` 额外写 `manual_reason` 和 `owner`;`blocked` 额外写 `blocked_by` 和 `owner`。
48
+ - `replay`:可用稳定步骤再次触发,必须写 `replay_scope`(`component|page|api|integration`)。页面优先拆到组件、页面、接口或集成边界,不要求 AI 在真实环境盲目重试。
49
+ - `causal`:用于已由 HAR、控制台、DOM 快照、服务日志或请求链路证明首个偏差、但暂不能稳定重放的缺陷。必须写 `causal_evidence_refs`(已有 `SRC-*` / `CODE-*`)和 `first_divergence`;后续执行绑定还必须提供实际证据文件,Core 会哈希留档。没有这些证据不能称为“已证实”。
50
+ - `observation`:用于当前无法重放也无法证明根因的偶发问题。必须写 `observation_plan` 的 `signal`、`owner`、`window`、`alert_condition`,且 `automation_mode` 必须为 `blocked`。它会阻止版本收口,不能被“人工已看过”替代。
47
51
  - 当 TC 承接结构性专项义务时,额外带对应引用并建立同样的 `verified_by` edge:`api_refs`(API→`api`)、`workload_refs`(WSH/WLC→`performance` 或 `migration`)、`risk_refs`(架构 RISK→`resilience`)。跨数据域的 REQ 必须有 `permission` TC,BC 必须有 `boundary` TC。
48
52
  - 页面/API 的具体 selector、endpoint URL、测试数据和 runner 命令属于后续执行绑定,不得改变本步骤的步骤或 oracle。
49
53
  - 高风险边界、权限、迁移、外部依赖和工作负载事实必须产生相应 TC;`test-obligation-check` 是机器判定,不接受“无架构影响”的模型自述。环境未准备好只能标记 blocked,不得省略。
@@ -36,6 +36,8 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
36
36
  - 通用自动化用例使用 `{ "kind": "command", "argv": [ ... ] }`,不使用 shell 字符串;
37
37
  - Core 生成的 API TC 可直接使用 `{ "kind": "http", "base_url_env": "DEVFLOW_TEST_BASE_URL", "headers_env": { "Authorization": "DEVFLOW_TEST_AUTHORIZATION" } }`。请求、HTTP 状态和异常码断言来自冻结 TC 的 `execution`,绑定只能提供环境地址和鉴权,不得重写测试意图;若场景需要专用夹具,可额外配置 `{ "setup": { "argv": [ ... ] } }`,Core 会先记录并执行该受控命令,再发 HTTP 请求;
38
38
  - 人工或环境阻塞用例必须有 owner 与原因/阻塞源。
39
+ - `evidence_strategy=causal` 的自动用例额外写 `evidence_files`(项目根目录内的 HAR、控制台、DOM 快照、截图或日志文件相对路径)。Core 在执行前校验文件存在且未越出项目根目录,并把内容哈希写入结果;不能只填 Markdown 描述。
40
+ - `evidence_strategy=observation` 只能保持 `blocked`,其观测计划是风险收口输入,不是测试通过证据。
39
41
  5. 调用 `devflow_run_acceptance_tests(action="validate", feature=...)`。不通过时修正绑定,不得绕过。
40
42
  6. 调用 `devflow_run_acceptance_tests(action="run", feature=...)`。该 Core 工具记录每个 TC 的命令哈希、退出码和输出摘要。
41
43
  7. 失败、未执行或 blocked 的 TC 只能报告为 `blocked`/`failed`,不得进入版本收口。
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.24";
2
+ export const PRODUCT_VERSION = "0.1.25";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5