its-magic 0.1.2-9 → 0.1.2

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.
Files changed (232) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +21 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +981 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +284 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +505 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/README.md +1602 -0
  65. package/template/decisions/DEC-0001.md +11 -0
  66. package/template/decisions/DEC-0002.md +11 -0
  67. package/template/docs/developer/README.md +44 -0
  68. package/template/docs/engineering/architecture.md +9 -0
  69. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  70. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  71. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  72. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  73. package/template/docs/engineering/compatibility-report.md +20 -0
  74. package/template/docs/engineering/compatibility-signals.md +7 -0
  75. package/template/docs/engineering/component-scope-report.md +16 -0
  76. package/template/docs/engineering/component-scope.md +19 -0
  77. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  78. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  79. package/template/docs/engineering/decisions.md +16 -0
  80. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  81. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  82. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  83. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  84. package/template/docs/engineering/phase-context.md +16 -0
  85. package/template/docs/engineering/release-targets.json +123 -0
  86. package/template/docs/engineering/research.md +29 -0
  87. package/template/docs/engineering/runbook.md +2320 -0
  88. package/template/docs/engineering/runtime-connectivity.md +81 -0
  89. package/template/docs/engineering/security-review.md +33 -0
  90. package/template/docs/engineering/spec-pack/README.md +20 -0
  91. package/template/docs/engineering/state-archive/README.md +15 -0
  92. package/template/docs/engineering/state.md +20 -0
  93. package/template/docs/engineering/status-normalization-report.md +19 -0
  94. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  95. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  96. package/template/docs/product/acceptance.md +1 -0
  97. package/template/docs/product/backlog.md +5 -0
  98. package/template/docs/product/vision.md +11 -0
  99. package/template/docs/user-guides/README.md +40 -0
  100. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  101. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  102. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  103. package/template/handoffs/release_notes.md +51 -0
  104. package/template/handoffs/release_queue.md +47 -0
  105. package/template/handoffs/release_to_dev.md +31 -0
  106. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  107. package/template/handoffs/resume_brief.md +14 -0
  108. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  109. package/template/handoffs/token_cost_runs/README.md +26 -0
  110. package/template/its_magic/.its-magic-version +1 -0
  111. package/template/its_magic/README.md +9 -0
  112. package/template/scripts/auto_outer_driver.py +521 -0
  113. package/template/scripts/caveman_compress_input.py +903 -0
  114. package/template/scripts/check_downstream_ci_guard.py +67 -0
  115. package/template/scripts/check_intake_template_parity.py +284 -0
  116. package/template/scripts/check_token_cost_parity.py +69 -0
  117. package/template/scripts/dev_environment_lib.py +463 -0
  118. package/template/scripts/doc_profile_lib.py +415 -0
  119. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  120. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  121. package/template/scripts/guard_installer_publish.py +88 -0
  122. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  123. package/template/scripts/intake_bug_routing_guard.py +67 -0
  124. package/template/scripts/intake_evidence_lib.py +802 -0
  125. package/template/scripts/intake_evidence_validate.py +73 -0
  126. package/template/scripts/materialize_codebase_map.py +184 -0
  127. package/template/scripts/pack_json_validate.py +130 -0
  128. package/template/scripts/project_readme_coverage_lib.py +417 -0
  129. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  130. package/template/scripts/remote_config_summary.py +243 -0
  131. package/template/scripts/sync_push_gates.py +198 -0
  132. package/template/scripts/token_cost_compare.py +40 -0
  133. package/template/scripts/token_cost_lib.py +108 -0
  134. package/template/scripts/uat_probe_lib.py +868 -0
  135. package/template/scripts/validate-and-push.ps1 +280 -0
  136. package/template/scripts/validate-and-push.sh +243 -0
  137. package/template/scripts/validate_doc_profile.py +103 -0
  138. package/template/scripts/validate_project_readme_coverage.py +151 -0
  139. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  140. package/template/sprints/S0001/progress.md +1 -0
  141. package/template/sprints/S0001/qa-findings.md +9 -0
  142. package/template/sprints/S0001/release-findings.md +24 -0
  143. package/template/sprints/S0001/sprint.md +9 -0
  144. package/template/sprints/S0001/summary.md +7 -0
  145. package/template/sprints/S0001/tasks.md +1 -0
  146. package/template/sprints/S0001/uat.json +6 -0
  147. package/template/sprints/S0001/uat.md +5 -0
  148. package/template/sprints/quick/Q0001/summary.md +1 -0
  149. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  150. package/.cursor/agents/curator.mdc +0 -21
  151. package/.cursor/agents/dev.mdc +0 -20
  152. package/.cursor/agents/po.mdc +0 -19
  153. package/.cursor/agents/qa.mdc +0 -19
  154. package/.cursor/agents/release.mdc +0 -19
  155. package/.cursor/agents/tech-lead.mdc +0 -21
  156. package/.cursor/commands/gsd-architecture.md +0 -29
  157. package/.cursor/commands/gsd-auto.md +0 -27
  158. package/.cursor/commands/gsd-discovery.md +0 -27
  159. package/.cursor/commands/gsd-execute.md +0 -32
  160. package/.cursor/commands/gsd-intake.md +0 -28
  161. package/.cursor/commands/gsd-map-codebase.md +0 -25
  162. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  163. package/.cursor/commands/gsd-milestone-start.md +0 -26
  164. package/.cursor/commands/gsd-pause.md +0 -25
  165. package/.cursor/commands/gsd-plan-verify.md +0 -26
  166. package/.cursor/commands/gsd-qa.md +0 -28
  167. package/.cursor/commands/gsd-quick.md +0 -24
  168. package/.cursor/commands/gsd-refresh-context.md +0 -26
  169. package/.cursor/commands/gsd-release.md +0 -29
  170. package/.cursor/commands/gsd-research.md +0 -28
  171. package/.cursor/commands/gsd-resume.md +0 -26
  172. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  173. package/.cursor/commands/gsd-verify-work.md +0 -25
  174. package/.cursor/hooks.json +0 -26
  175. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  176. package/.cursor/remote.json +0 -18
  177. package/.cursor/rules/gsd-core.mdc +0 -18
  178. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  179. package/.cursor/rules/gsd-quality.mdc +0 -15
  180. package/.cursor/scratchpad.md +0 -34
  181. package/.github/workflows/ci.yml +0 -47
  182. package/decisions/DEC-0001.md +0 -21
  183. package/decisions/DEC-0002.md +0 -21
  184. package/docs/engineering/architecture.md +0 -354
  185. package/docs/engineering/decisions.md +0 -6
  186. package/docs/engineering/research.md +0 -11
  187. package/docs/engineering/runbook.md +0 -32
  188. package/docs/engineering/state.md +0 -33
  189. package/docs/product/acceptance.md +0 -6
  190. package/docs/product/backlog.md +0 -7
  191. package/docs/product/vision.md +0 -46
  192. package/gsd-installer.ps1 +0 -189
  193. package/gsd-installer.py +0 -195
  194. package/gsd-installer.sh +0 -201
  195. package/handoffs/release_notes.md +0 -14
  196. package/handoffs/resume_brief.md +0 -8
  197. package/milestones/M0001/milestone.json +0 -7
  198. package/milestones/M0001/phases.json +0 -9
  199. package/milestones/M0001/progress.md +0 -3
  200. package/milestones/M0001/summary.md +0 -3
  201. package/scripts/generate-release-notes.ps1 +0 -74
  202. package/scripts/generate-release-notes.sh +0 -63
  203. package/scripts/release-all.ps1 +0 -423
  204. package/scripts/release-all.sh +0 -226
  205. package/sprints/S0001/progress.md +0 -4
  206. package/sprints/S0001/qa-findings.md +0 -113
  207. package/sprints/S0001/sprint.md +0 -70
  208. package/sprints/S0001/summary.md +0 -46
  209. package/sprints/S0001/tasks.md +0 -35
  210. package/sprints/S0001/uat.json +0 -8
  211. package/sprints/S0001/uat.md +0 -8
  212. package/sprints/quick/Q0001/summary.md +0 -3
  213. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  214. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  215. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  216. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  217. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  218. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  230. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  231. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  232. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,802 @@
1
+ """
2
+ Deterministic intake evidence validation
3
+ (US-0078 / US-0083 / DEC-0060 / DEC-0067 / R-0055 / BUG-0007 / R-0066).
4
+
5
+ Consumes a logical intake_evidence bundle (dict). PO workflows MUST run this
6
+ gate before mutating backlog/acceptance; failures are fail-closed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ IE_REF_RE = re.compile(r"^ie:([^:]+):(\d+):([0-9a-f]{16})$")
18
+ PLAN_AREA_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
19
+
20
+ PACK_REQUIRED_KEYS: dict[str, tuple[str, ...]] = {
21
+ "first-intake-pack": (
22
+ "users_problem",
23
+ "runtime_target_environment",
24
+ "language_framework_runtime",
25
+ "architecture_preference",
26
+ "ui_design_expectations",
27
+ "security_compliance",
28
+ "non_functional_priorities",
29
+ "scope_timeline",
30
+ ),
31
+ "small-intake-pack": (
32
+ "outcome_success_criteria",
33
+ "impacted_components",
34
+ "constraints_compatibility_risks",
35
+ "required_tests_acceptance_checks",
36
+ "done_definition",
37
+ ),
38
+ }
39
+
40
+ ASSUMPTIONS_TOPIC_KEY = "assumptions_bundle"
41
+
42
+ SAFE_ASSUMPTION_LITERALS = frozenset(
43
+ {
44
+ "",
45
+ "(none)",
46
+ "none",
47
+ "no",
48
+ "false",
49
+ "n/a",
50
+ "na",
51
+ "n",
52
+ }
53
+ )
54
+
55
+ FALSE_CONFIRMATION_LITERALS = frozenset({"yes", "true", "confirmed"})
56
+ ALLOWED_SATISFIED_BY = frozenset({"answer_ref", "assumption_confirmation_ref", "delegation_ref"})
57
+ DELEGATION_CONFIDENCE_VALUES = frozenset({"low", "medium", "high"})
58
+
59
+
60
+ def canonical_json_sha256_16(obj: dict[str, Any]) -> str:
61
+ """Sorted-key compact JSON UTF-8 → first 16 hex chars of SHA-256 (DEC-0060)."""
62
+ blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
63
+ return hashlib.sha256(blob).hexdigest()[:16]
64
+
65
+
66
+ def build_ie_ref(
67
+ intake_run_id: str,
68
+ turn_index: int,
69
+ topic_key: str,
70
+ satisfied_by: str,
71
+ quoted_user_text: str,
72
+ ) -> str:
73
+ """Construct ie: ref per DEC-0060 §4."""
74
+ payload = {
75
+ "intake_run_id": intake_run_id,
76
+ "turn_index": int(turn_index),
77
+ "topic_key": topic_key,
78
+ "satisfied_by": satisfied_by,
79
+ "quoted_user_text": (quoted_user_text or "").strip(),
80
+ }
81
+ digest = canonical_json_sha256_16(payload)
82
+ return f"ie:{intake_run_id}:{int(turn_index)}:{digest}"
83
+
84
+
85
+ def parse_ie_ref(ref: str) -> tuple[str, int, str] | None:
86
+ if not ref or not isinstance(ref, str):
87
+ return None
88
+ m = IE_REF_RE.match(ref.strip())
89
+ if not m:
90
+ return None
91
+ return m.group(1), int(m.group(2)), m.group(3)
92
+
93
+
94
+ def verify_ie_ref(
95
+ ref: str,
96
+ *,
97
+ intake_run_id: str,
98
+ turn_index: int,
99
+ topic_key: str,
100
+ satisfied_by: str,
101
+ quoted_user_text: str,
102
+ ) -> bool:
103
+ parsed = parse_ie_ref(ref)
104
+ if not parsed:
105
+ return False
106
+ prun, pturn, phash = parsed
107
+ if prun != intake_run_id or pturn != int(turn_index):
108
+ return False
109
+ payload = {
110
+ "intake_run_id": intake_run_id,
111
+ "turn_index": int(turn_index),
112
+ "topic_key": topic_key,
113
+ "satisfied_by": satisfied_by,
114
+ "quoted_user_text": (quoted_user_text or "").strip(),
115
+ }
116
+ return canonical_json_sha256_16(payload) == phash
117
+
118
+
119
+ def _norm_assumption(s: str) -> str:
120
+ return (s or "").strip()
121
+
122
+
123
+ def assumption_literal_requires_confirmation_ref(value: str) -> bool:
124
+ """R-0055 rules 4–5 + DEC-0060: non-placeholder confirmations need evidence."""
125
+ v = _norm_assumption(value)
126
+ if not v:
127
+ return False
128
+ low = v.lower()
129
+ if low in SAFE_ASSUMPTION_LITERALS:
130
+ return False
131
+ if low in FALSE_CONFIRMATION_LITERALS:
132
+ return True
133
+ # Any other non-empty narrative still implies confirmation was recorded → needs ref
134
+ return True
135
+
136
+
137
+ @dataclass
138
+ class ValidationResult:
139
+ ok: bool
140
+ primary_codes: list[str] = field(default_factory=list)
141
+ missing_topics: list[str] = field(default_factory=list)
142
+ diagnostics: list[str] = field(default_factory=list)
143
+
144
+ def add_code(self, code: str) -> None:
145
+ if code not in self.primary_codes:
146
+ self.primary_codes.append(code)
147
+
148
+
149
+ def _topic_rows(bundle: dict[str, Any]) -> list[dict[str, Any]]:
150
+ raw = bundle.get("topic_coverage")
151
+ if raw is None:
152
+ return []
153
+ if not isinstance(raw, list):
154
+ return []
155
+ out: list[dict[str, Any]] = []
156
+ for row in raw:
157
+ if isinstance(row, dict):
158
+ out.append(row)
159
+ return out
160
+
161
+
162
+ def _asked_set(bundle: dict[str, Any]) -> set[str]:
163
+ at = bundle.get("asked_topics")
164
+ if at is None:
165
+ return set()
166
+ if isinstance(at, str):
167
+ parts = [p.strip() for p in at.replace("\n", ",").split(",")]
168
+ return {p for p in parts if p}
169
+ if isinstance(at, list):
170
+ return {str(x).strip() for x in at if str(x).strip()}
171
+ return set()
172
+
173
+
174
+ def _row_run_id(bundle: dict[str, Any], row: dict[str, Any]) -> str | None:
175
+ v = row.get("intake_run_id")
176
+ if v is not None and str(v).strip():
177
+ return str(v).strip()
178
+ v2 = bundle.get("intake_run_id")
179
+ if v2 is not None and str(v2).strip():
180
+ return str(v2).strip()
181
+ return None
182
+
183
+
184
+ def _row_turn(row: dict[str, Any]) -> int | None:
185
+ t = row.get("turn_index")
186
+ if t is None:
187
+ return None
188
+ try:
189
+ return int(t)
190
+ except (TypeError, ValueError):
191
+ return None
192
+
193
+
194
+ def _row_uses_equivalent_evidence(row: dict[str, Any]) -> bool:
195
+ """
196
+ US-0083 AC-1: allow required-topic accounting without forcing repetitive asks
197
+ when equivalent evidence is already captured and explicitly referenced.
198
+ """
199
+ marker = str(row.get("evidence_source") or "").strip()
200
+ if marker != "equivalent_evidence_ref":
201
+ return False
202
+ return bool(str(row.get("equivalent_evidence_ref") or "").strip())
203
+
204
+
205
+ def _norm_answer_ref_quoted_user_text(value: Any) -> str:
206
+ """Normalize quoted_user_text for BUG-0007 duplicate detection (DEC-0060 strip parity)."""
207
+ if value is None:
208
+ return ""
209
+ return str(value).strip()
210
+
211
+
212
+ def _row_exempt_from_answer_ref_topic_distinctness(row: dict[str, Any]) -> bool:
213
+ """BUG-0007: alternate satisfaction paths do not participate in answer_ref blob reuse checks."""
214
+ return _row_uses_equivalent_evidence(row)
215
+
216
+
217
+ def _validate_answer_ref_topic_distinctness(
218
+ bundle: dict[str, Any],
219
+ required: list[str],
220
+ by_key: dict[str, dict[str, Any]],
221
+ res: ValidationResult,
222
+ ) -> None:
223
+ """
224
+ BUG-0007 / R-0066: distinct required topic_key rows must not reuse the same
225
+ quoted_user_text under satisfied_by=answer_ref (normalized), except exempt rows.
226
+ """
227
+ norm_to_topics: dict[str, list[str]] = {}
228
+ for k in required:
229
+ row = by_key.get(k)
230
+ if not row:
231
+ continue
232
+ sat = (row.get("satisfied_by") or "").strip()
233
+ if sat != "answer_ref":
234
+ continue
235
+ if _row_exempt_from_answer_ref_topic_distinctness(row):
236
+ continue
237
+ irid = _row_run_id(bundle, row)
238
+ tit = _row_turn(row)
239
+ if irid is None or tit is None:
240
+ continue
241
+ qraw = row.get("quoted_user_text")
242
+ qtxt = "" if qraw is None else str(qraw)
243
+ ref = (row.get("ref") or "").strip()
244
+ if not ref or not verify_ie_ref(
245
+ ref,
246
+ intake_run_id=irid,
247
+ turn_index=int(tit),
248
+ topic_key=k,
249
+ satisfied_by=sat,
250
+ quoted_user_text=qtxt,
251
+ ):
252
+ continue
253
+ norm = _norm_answer_ref_quoted_user_text(qraw)
254
+ norm_to_topics.setdefault(norm, []).append(k)
255
+
256
+ dup_groups: list[str] = []
257
+ for norm, topics in norm_to_topics.items():
258
+ uniq = sorted(set(topics))
259
+ if len(uniq) < 2:
260
+ continue
261
+ dup_groups.append("text=" + repr(norm[:120]) + " topics=" + ",".join(uniq))
262
+
263
+ if dup_groups:
264
+ res.ok = False
265
+ res.add_code("INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT")
266
+ res.diagnostics.append(
267
+ "Remediation: distinct required topics must not reuse the same quoted_user_text "
268
+ "under satisfied_by=answer_ref (BUG-0007 / R-0066). Duplicates: "
269
+ + "; ".join(dup_groups)
270
+ + ". Use per-topic answers, or an allowed alternate path "
271
+ "(evidence_source=equivalent_evidence_ref + equivalent_evidence_ref, "
272
+ "delegation_ref per DEC-0067 / US-0083, or assumption_confirmation_ref on the row)."
273
+ )
274
+
275
+
276
+ def _candidate_story_ids(bundle: dict[str, Any]) -> set[str]:
277
+ out: set[str] = set()
278
+
279
+ raw_ids = bundle.get("candidate_story_ids")
280
+ if isinstance(raw_ids, list):
281
+ for sid in raw_ids:
282
+ sv = str(sid).strip()
283
+ if sv:
284
+ out.add(sv)
285
+
286
+ raw_story_ids = bundle.get("story_ids")
287
+ if isinstance(raw_story_ids, list):
288
+ for sid in raw_story_ids:
289
+ sv = str(sid).strip()
290
+ if sv:
291
+ out.add(sv)
292
+
293
+ raw_story_map = bundle.get("story_map")
294
+ if isinstance(raw_story_map, list):
295
+ for row in raw_story_map:
296
+ if not isinstance(row, dict):
297
+ continue
298
+ sv = str(row.get("story_id") or "").strip()
299
+ if sv:
300
+ out.add(sv)
301
+
302
+ return out
303
+
304
+
305
+ def _validate_plan_coverage_contract(bundle: dict[str, Any], res: ValidationResult) -> None:
306
+ inventory_raw = bundle.get("plan_area_inventory")
307
+ coverage_raw = bundle.get("plan_area_coverage")
308
+ coverage_complete_raw = bundle.get("coverage_complete")
309
+ candidate_story_ids = _candidate_story_ids(bundle)
310
+
311
+ inventory_rows = inventory_raw if isinstance(inventory_raw, list) else []
312
+ coverage_rows = coverage_raw if isinstance(coverage_raw, list) else []
313
+
314
+ inventory_ids: list[str] = []
315
+ coverage_by_id: dict[str, dict[str, Any]] = {}
316
+
317
+ coverage_missing = False
318
+ id_invalid = False
319
+ contract_invalid = False
320
+ deferred_ref_missing = False
321
+
322
+ if not inventory_rows:
323
+ coverage_missing = True
324
+ res.diagnostics.append(
325
+ "Remediation: plan_area_inventory must be a non-empty list for first/new/broad intake."
326
+ )
327
+ if not coverage_rows:
328
+ coverage_missing = True
329
+ res.diagnostics.append(
330
+ "Remediation: plan_area_coverage must be a non-empty list for first/new/broad intake."
331
+ )
332
+
333
+ seen_inventory_ids: set[str] = set()
334
+ for row in inventory_rows:
335
+ if not isinstance(row, dict):
336
+ id_invalid = True
337
+ continue
338
+ plan_area_id = str(row.get("plan_area_id") or "").strip()
339
+ if not PLAN_AREA_ID_RE.match(plan_area_id):
340
+ id_invalid = True
341
+ continue
342
+ if plan_area_id in seen_inventory_ids:
343
+ id_invalid = True
344
+ continue
345
+ seen_inventory_ids.add(plan_area_id)
346
+ inventory_ids.append(plan_area_id)
347
+
348
+ for row in coverage_rows:
349
+ if not isinstance(row, dict):
350
+ id_invalid = True
351
+ continue
352
+ plan_area_id = str(row.get("plan_area_id") or "").strip()
353
+ if not PLAN_AREA_ID_RE.match(plan_area_id):
354
+ id_invalid = True
355
+ continue
356
+ if plan_area_id in coverage_by_id:
357
+ id_invalid = True
358
+ continue
359
+ coverage_by_id[plan_area_id] = row
360
+
361
+ inventory_id_set = set(inventory_ids)
362
+ coverage_id_set = set(coverage_by_id.keys())
363
+
364
+ if inventory_id_set != coverage_id_set:
365
+ coverage_missing = True
366
+ missing_ids = sorted(inventory_id_set - coverage_id_set)
367
+ extra_ids = sorted(coverage_id_set - inventory_id_set)
368
+ if missing_ids:
369
+ res.diagnostics.append(
370
+ "Remediation: add plan_area_coverage rows for uncovered plan_area_id values: "
371
+ + ", ".join(missing_ids)
372
+ )
373
+ if extra_ids:
374
+ contract_invalid = True
375
+ res.diagnostics.append(
376
+ "Remediation: remove unknown plan_area_coverage plan_area_id values not in plan_area_inventory: "
377
+ + ", ".join(extra_ids)
378
+ )
379
+
380
+ for plan_area_id in sorted(inventory_id_set & coverage_id_set):
381
+ row = coverage_by_id[plan_area_id]
382
+ story_ids_raw = row.get("story_ids")
383
+ deferred_ref = str(row.get("deferred_ref") or "").strip()
384
+
385
+ story_ids: list[str] = []
386
+ if isinstance(story_ids_raw, list):
387
+ for sid in story_ids_raw:
388
+ sv = str(sid).strip()
389
+ if sv:
390
+ story_ids.append(sv)
391
+ has_story_ids = bool(story_ids)
392
+ has_deferred_ref = bool(deferred_ref)
393
+
394
+ if has_story_ids == has_deferred_ref:
395
+ contract_invalid = True
396
+ res.diagnostics.append(
397
+ "Remediation: each plan_area_coverage row must set exactly one mapping path "
398
+ "(story_ids xor deferred_ref) for plan_area_id "
399
+ + repr(plan_area_id)
400
+ + "."
401
+ )
402
+ continue
403
+
404
+ if has_story_ids:
405
+ if candidate_story_ids:
406
+ unknown_story_ids = sorted({sid for sid in story_ids if sid not in candidate_story_ids})
407
+ if unknown_story_ids:
408
+ contract_invalid = True
409
+ res.diagnostics.append(
410
+ "Remediation: plan_area_id "
411
+ + repr(plan_area_id)
412
+ + " references unknown story_ids not present in candidate story set: "
413
+ + ", ".join(unknown_story_ids)
414
+ )
415
+ else:
416
+ deferred_reason = str(row.get("deferred_reason") or "").strip()
417
+ if not deferred_reason:
418
+ deferred_ref_missing = True
419
+ res.diagnostics.append(
420
+ "Remediation: deferred mapping for plan_area_id "
421
+ + repr(plan_area_id)
422
+ + " requires both deferred_ref and deferred_reason."
423
+ )
424
+
425
+ derived_coverage_complete = not (coverage_missing or id_invalid or contract_invalid or deferred_ref_missing)
426
+ if coverage_complete_raw is not True:
427
+ contract_invalid = True
428
+ res.diagnostics.append(
429
+ "Remediation: set coverage_complete=true only after plan_area_inventory and "
430
+ "plan_area_coverage pass deterministic validation."
431
+ )
432
+ if bool(coverage_complete_raw) != derived_coverage_complete:
433
+ contract_invalid = True
434
+ res.diagnostics.append(
435
+ "Remediation: coverage_complete must match derived contract result from "
436
+ "plan_area_inventory/plan_area_coverage validation."
437
+ )
438
+
439
+ if coverage_missing:
440
+ res.ok = False
441
+ res.add_code("INTAKE_PLAN_COVERAGE_MISSING")
442
+ if id_invalid:
443
+ res.ok = False
444
+ res.add_code("INTAKE_PLAN_AREA_ID_INVALID")
445
+ res.diagnostics.append(
446
+ "Remediation: plan_area_id must be unique and match ^[a-z0-9][a-z0-9_-]{1,63}$ in inventory and coverage."
447
+ )
448
+ if deferred_ref_missing:
449
+ res.ok = False
450
+ res.add_code("INTAKE_PLAN_DEFERRED_REF_MISSING")
451
+ if contract_invalid:
452
+ res.ok = False
453
+ res.add_code("INTAKE_PLAN_COVERAGE_CONTRACT_INVALID")
454
+
455
+
456
+ def validate_intake_evidence(
457
+ bundle: dict[str, Any],
458
+ *,
459
+ intake_guided_mode: int | None = None,
460
+ ) -> ValidationResult:
461
+ """
462
+ Validate logical intake_evidence. intake_guided_mode is accepted for API parity
463
+ (AC-5/AC-6): validation rules do not branch on mode — same pipeline for {0,1}.
464
+ """
465
+ _ = intake_guided_mode # explicit no-op for parity contract
466
+ res = ValidationResult(ok=True)
467
+
468
+ pack = (bundle.get("selected_pack") or "").strip()
469
+ if pack not in PACK_REQUIRED_KEYS:
470
+ res.ok = False
471
+ res.add_code("INTAKE_REQUIRED_PACK_INCOMPLETE")
472
+ res.diagnostics.append(
473
+ f"Remediation: set selected_pack to one of {sorted(PACK_REQUIRED_KEYS.keys())!r}; "
474
+ f"unknown or empty pack is not allowed before persistence."
475
+ )
476
+ res.add_code("INTAKE_PERSISTENCE_BLOCKED")
477
+ return res
478
+
479
+ required = list(PACK_REQUIRED_KEYS[pack])
480
+ rows = _topic_rows(bundle)
481
+ by_key: dict[str, dict[str, Any]] = {}
482
+ for row in rows:
483
+ k = (row.get("topic_key") or "").strip()
484
+ if k:
485
+ by_key[k] = row
486
+
487
+ missing_cov: list[str] = []
488
+ for k in required:
489
+ row = by_key.get(k)
490
+ if not row:
491
+ missing_cov.append(k)
492
+ continue
493
+
494
+ ref = (row.get("ref") or "").strip()
495
+ sat = (row.get("satisfied_by") or "").strip()
496
+ qtxt = row.get("quoted_user_text")
497
+ if qtxt is None:
498
+ qtxt = ""
499
+ qtxt = str(qtxt)
500
+
501
+ if sat not in ALLOWED_SATISFIED_BY:
502
+ res.ok = False
503
+ res.diagnostics.append(
504
+ f"Remediation: topic {k!r} must set satisfied_by to "
505
+ f"'answer_ref', 'assumption_confirmation_ref', or 'delegation_ref' (got {sat!r})."
506
+ )
507
+ missing_cov.append(k)
508
+ continue
509
+
510
+ if sat == "delegation_ref":
511
+ scope = str(row.get("delegation_scope") or "").strip()
512
+ rationale = str(row.get("delegation_rationale") or "").strip()
513
+ confidence = str(row.get("delegation_confidence") or "").strip().lower()
514
+ if not scope or not rationale or not confidence:
515
+ res.ok = False
516
+ res.add_code("INTAKE_DELEGATION_EVIDENCE_MISSING")
517
+ missing_fields = []
518
+ if not scope:
519
+ missing_fields.append("delegation_scope")
520
+ if not rationale:
521
+ missing_fields.append("delegation_rationale")
522
+ if not confidence:
523
+ missing_fields.append("delegation_confidence")
524
+ res.diagnostics.append(
525
+ f"Remediation: delegated topic {k!r} requires non-empty fields: "
526
+ + ", ".join(missing_fields)
527
+ + "."
528
+ )
529
+ continue
530
+ if confidence not in DELEGATION_CONFIDENCE_VALUES:
531
+ res.ok = False
532
+ res.add_code("INTAKE_DELEGATION_EVIDENCE_INVALID")
533
+ res.diagnostics.append(
534
+ f"Remediation: delegated topic {k!r} delegation_confidence must be one of "
535
+ f"{sorted(DELEGATION_CONFIDENCE_VALUES)!r} (got {confidence!r})."
536
+ )
537
+ continue
538
+
539
+ irid = _row_run_id(bundle, row)
540
+ tit = _row_turn(row)
541
+ if irid is None or tit is None:
542
+ res.ok = False
543
+ res.diagnostics.append(
544
+ f"Remediation: topic {k!r} needs intake_run_id and turn_index (row or bundle) "
545
+ f"for ie: ref verification."
546
+ )
547
+ missing_cov.append(k)
548
+ continue
549
+
550
+ if not ref or not verify_ie_ref(
551
+ ref,
552
+ intake_run_id=irid,
553
+ turn_index=tit,
554
+ topic_key=k,
555
+ satisfied_by=sat,
556
+ quoted_user_text=qtxt,
557
+ ):
558
+ res.ok = False
559
+ if sat == "delegation_ref":
560
+ res.add_code("INTAKE_DELEGATION_EVIDENCE_INVALID")
561
+ res.diagnostics.append(
562
+ f"Remediation: delegated topic {k!r} ref is malformed or hash mismatch — rebuild ie: ref "
563
+ f"with DEC-0060 canonical JSON (sorted keys) and quoted_user_text."
564
+ )
565
+ else:
566
+ res.diagnostics.append(
567
+ f"Remediation: topic {k!r} ref is malformed or hash mismatch — rebuild ie: ref "
568
+ f"with DEC-0060 canonical JSON (sorted keys) and quoted_user_text."
569
+ )
570
+ missing_cov.append(k)
571
+
572
+ if missing_cov:
573
+ res.ok = False
574
+ res.missing_topics = sorted(set(missing_cov))
575
+ res.add_code("INTAKE_REQUIRED_TOPIC_MISSING")
576
+ if len(res.missing_topics) > 1:
577
+ res.add_code("INTAKE_REQUIRED_PACK_INCOMPLETE")
578
+ res.diagnostics.append(
579
+ "Remediation: supply complete topic_coverage with valid ie: refs for: "
580
+ + ", ".join(res.missing_topics)
581
+ )
582
+
583
+ asked = _asked_set(bundle)
584
+ for k in required:
585
+ if k not in by_key:
586
+ continue
587
+ if k not in asked:
588
+ row = by_key[k]
589
+ if _row_uses_equivalent_evidence(row):
590
+ continue
591
+ res.ok = False
592
+ res.add_code("INTAKE_REQUIRED_TOPIC_MISSING")
593
+ res.diagnostics.append(
594
+ f"Remediation: add {k!r} to asked_topics or mark evidence_source='equivalent_evidence_ref' "
595
+ f"with equivalent_evidence_ref when reusing previously captured equivalent evidence."
596
+ )
597
+ if k not in res.missing_topics:
598
+ res.missing_topics.append(k)
599
+ res.missing_topics = sorted(set(res.missing_topics))
600
+
601
+ _validate_answer_ref_topic_distinctness(bundle, required, by_key, res)
602
+
603
+ # US-0081 / DEC-0064: first/new/broad intake requires complete-plan coverage contract.
604
+ if pack == "first-intake-pack":
605
+ _validate_plan_coverage_contract(bundle, res)
606
+
607
+ ac = bundle.get("assumptions_confirmed")
608
+ ac_str = ac if isinstance(ac, str) else ("(none)" if ac is None else str(ac))
609
+
610
+ low = _norm_assumption(ac_str).lower()
611
+ if low in FALSE_CONFIRMATION_LITERALS and not (bundle.get("assumption_confirmation_ref") or "").strip():
612
+ res.ok = False
613
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
614
+ res.diagnostics.append(
615
+ "Remediation: literal yes/true/confirmed is rejected without "
616
+ "assumption_confirmation_ref (R-0055 rule 5)."
617
+ )
618
+
619
+ if assumption_literal_requires_confirmation_ref(ac_str):
620
+ aref = (bundle.get("assumption_confirmation_ref") or "").strip()
621
+ aquote = str(bundle.get("assumption_confirmation_quoted") or "")
622
+ irid = bundle.get("assumption_confirmation_intake_run_id")
623
+ tit = bundle.get("assumption_confirmation_turn_index")
624
+ if not aref or irid is None or tit is None:
625
+ res.ok = False
626
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
627
+ res.diagnostics.append(
628
+ "Remediation: affirmative assumptions_confirmed requires assumption_confirmation_ref "
629
+ "plus assumption_confirmation_intake_run_id, assumption_confirmation_turn_index, "
630
+ "and assumption_confirmation_quoted for ie: binding."
631
+ )
632
+ elif not verify_ie_ref(
633
+ aref,
634
+ intake_run_id=str(irid),
635
+ turn_index=int(tit),
636
+ topic_key=ASSUMPTIONS_TOPIC_KEY,
637
+ satisfied_by="assumption_confirmation_ref",
638
+ quoted_user_text=aquote,
639
+ ):
640
+ res.ok = False
641
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
642
+ res.diagnostics.append(
643
+ "Remediation: assumption_confirmation_ref is invalid or does not match "
644
+ "quoted affirmative user text / run metadata."
645
+ )
646
+
647
+ if not res.ok and "INTAKE_PERSISTENCE_BLOCKED" not in res.primary_codes:
648
+ res.add_code("INTAKE_PERSISTENCE_BLOCKED")
649
+
650
+ return res
651
+
652
+
653
+ def format_blocked_message(result: ValidationResult) -> str:
654
+ lines = [
655
+ "INTAKE_PERSISTENCE_BLOCKED",
656
+ "primary_codes=" + ",".join(result.primary_codes),
657
+ ]
658
+ if result.missing_topics:
659
+ lines.append("missing_topics=" + ",".join(result.missing_topics))
660
+ lines.extend(result.diagnostics)
661
+ return "\n".join(lines)
662
+
663
+
664
+ def self_test() -> None:
665
+ """Minimal sanity checks for --self-test / installer wiring."""
666
+ rid = "selftest-run"
667
+ ref = build_ie_ref(rid, 0, "outcome_success_criteria", "answer_ref", "ok")
668
+ assert parse_ie_ref(ref) is not None
669
+ assert verify_ie_ref(
670
+ ref,
671
+ intake_run_id=rid,
672
+ turn_index=0,
673
+ topic_key="outcome_success_criteria",
674
+ satisfied_by="answer_ref",
675
+ quoted_user_text="ok",
676
+ )
677
+ assert not verify_ie_ref(
678
+ ref,
679
+ intake_run_id=rid,
680
+ turn_index=0,
681
+ topic_key="outcome_success_criteria",
682
+ satisfied_by="answer_ref",
683
+ quoted_user_text="tampered",
684
+ )
685
+ # Mode argument must not change outcome (AC-6 / AC-5 parity)
686
+ small = PACK_REQUIRED_KEYS["small-intake-pack"]
687
+ rows = []
688
+ for i, key in enumerate(small):
689
+ rows.append(
690
+ {
691
+ "topic_key": key,
692
+ "satisfied_by": "answer_ref",
693
+ "quoted_user_text": f"a{i}",
694
+ "intake_run_id": rid,
695
+ "turn_index": i,
696
+ "ref": build_ie_ref(rid, i, key, "answer_ref", f"a{i}"),
697
+ }
698
+ )
699
+ bundle = {
700
+ "selected_pack": "small-intake-pack",
701
+ "intake_run_id": rid,
702
+ "asked_topics": list(small),
703
+ "missing_topics": [],
704
+ "assumptions_confirmed": "(none)",
705
+ "topic_coverage": rows,
706
+ }
707
+ r0 = validate_intake_evidence(bundle, intake_guided_mode=0)
708
+ r1 = validate_intake_evidence(bundle, intake_guided_mode=1)
709
+ assert r0.ok and r1.ok
710
+ assert r0.primary_codes == r1.primary_codes
711
+
712
+ # US-0083 delegated-topic pass path
713
+ delegated_key = "done_definition"
714
+ rows2 = []
715
+ for i, key in enumerate(small):
716
+ sat = "delegation_ref" if key == delegated_key else "answer_ref"
717
+ txt = f"d{i}"
718
+ row = {
719
+ "topic_key": key,
720
+ "satisfied_by": sat,
721
+ "quoted_user_text": txt,
722
+ "intake_run_id": rid,
723
+ "turn_index": 200 + i,
724
+ "ref": build_ie_ref(rid, 200 + i, key, sat, txt),
725
+ }
726
+ if sat == "delegation_ref":
727
+ row["delegation_scope"] = "Implementation defaults for done criteria wording"
728
+ row["delegation_rationale"] = "User asked to proceed without additional specificity."
729
+ row["delegation_confidence"] = "medium"
730
+ rows2.append(row)
731
+ delegated_bundle = {
732
+ "selected_pack": "small-intake-pack",
733
+ "intake_run_id": rid,
734
+ "asked_topics": list(small),
735
+ "missing_topics": [],
736
+ "assumptions_confirmed": "(none)",
737
+ "topic_coverage": rows2,
738
+ }
739
+ d0 = validate_intake_evidence(delegated_bundle, intake_guided_mode=0)
740
+ d1 = validate_intake_evidence(delegated_bundle, intake_guided_mode=1)
741
+ assert d0.ok and d1.ok
742
+ assert d0.primary_codes == d1.primary_codes
743
+
744
+ # BUG-0007: same quoted_user_text across multiple answer_ref required topics fails
745
+ dup_txt = "synthetic blob echoed for every topic"
746
+ dup_rows = []
747
+ for i, key in enumerate(small):
748
+ dup_rows.append(
749
+ {
750
+ "topic_key": key,
751
+ "satisfied_by": "answer_ref",
752
+ "quoted_user_text": dup_txt,
753
+ "intake_run_id": rid,
754
+ "turn_index": 300 + i,
755
+ "ref": build_ie_ref(rid, 300 + i, key, "answer_ref", dup_txt),
756
+ }
757
+ )
758
+ dup_bundle = {
759
+ "selected_pack": "small-intake-pack",
760
+ "intake_run_id": rid,
761
+ "asked_topics": list(small),
762
+ "missing_topics": [],
763
+ "assumptions_confirmed": "(none)",
764
+ "topic_coverage": dup_rows,
765
+ }
766
+ dup_res = validate_intake_evidence(dup_bundle)
767
+ assert not dup_res.ok
768
+ assert "INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT" in dup_res.primary_codes
769
+
770
+ # First-intake full-plan coverage contract (US-0081 / DEC-0064)
771
+ first = PACK_REQUIRED_KEYS["first-intake-pack"]
772
+ first_rows = []
773
+ for i, key in enumerate(first):
774
+ first_rows.append(
775
+ {
776
+ "topic_key": key,
777
+ "satisfied_by": "answer_ref",
778
+ "quoted_user_text": f"b{i}",
779
+ "intake_run_id": rid,
780
+ "turn_index": i,
781
+ "ref": build_ie_ref(rid, i, key, "answer_ref", f"b{i}"),
782
+ }
783
+ )
784
+ full_bundle = {
785
+ "selected_pack": "first-intake-pack",
786
+ "intake_run_id": rid,
787
+ "asked_topics": list(first),
788
+ "missing_topics": [],
789
+ "assumptions_confirmed": "(none)",
790
+ "topic_coverage": first_rows,
791
+ "candidate_story_ids": ["US-9001", "US-9002"],
792
+ "plan_area_inventory": [
793
+ {"plan_area_id": "auth", "title": "Auth"},
794
+ {"plan_area_id": "billing", "title": "Billing"},
795
+ ],
796
+ "plan_area_coverage": [
797
+ {"plan_area_id": "auth", "story_ids": ["US-9001"]},
798
+ {"plan_area_id": "billing", "story_ids": ["US-9002"]},
799
+ ],
800
+ "coverage_complete": True,
801
+ }
802
+ assert validate_intake_evidence(full_bundle, intake_guided_mode=0).ok