its-magic 0.1.2-35 → 0.1.2-37

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
@@ -367,6 +367,7 @@ deterministic **`intake_evidence`** gate — **`topic_coverage`** with valid **`
367
367
  asked-vs-covered alignment, and **`assumption_confirmation_ref`** when assumptions are affirmative.
368
368
 
369
369
  - Run `python scripts/intake_evidence_validate.py --self-test` (also exercised via `tests/run-tests.*` §26k).
370
+ - **Packaged installs (BUG-0001 / DEC-0063)**: the intake gate modules (`intake_evidence_validate.py`, `intake_evidence_lib.py`, `intake_bug_routing_guard.py`) ship under **`template/scripts/`** and hydrate consumer repos at **`scripts/`** (npm **`files`**, Chocolatey/Homebrew **`template/`** tree, **`installer.ps1` / `installer.sh`** + **`installer-owned-paths.manifest`**). **`--mode upgrade`** treats them as framework files (added/updated like other shipped scripts). CI parity: **`python scripts/check_intake_template_parity.py --repo .`** (`tests/run-tests.*` §26N).
370
371
  - Operator docs: **`decisions/DEC-0060.md`**, **`docs/engineering/architecture.md`** **`# US-0078`**, runbook section **Interactive intake evidence validation (US-0078 / DEC-0060)**.
371
372
  - **Guided** and **low-touch** share the **same pre-persistence validation pipeline**; low-touch does not bypass mandatory pack coverage.
372
373
 
@@ -430,6 +431,20 @@ Compaction behavior:
430
431
  - `ARCH_HOT_MAX_STORY_SECTIONS` (default `120`)
431
432
  Triad hot surfaces (`state.md`, `handoffs/po_to_tl.md`,
432
433
  `docs/engineering/architecture.md`) must stay within merged scratchpad caps.
434
+
435
+ ### Token-cost measurement and low-cache patterns (US-0080 / DEC-0062)
436
+
437
+ - Prefer **fresh subagent/chat boundaries** per `/auto` phase spawn (see `.cursor/commands/auto.md`).
438
+ - Use explicit **`/auto start-from=<phase>`** when resuming so **`resolved_phase_plan`**
439
+ intersection stays deterministic (**`DEC-0052`**).
440
+ - Select **`TOKEN_PROFILE=lean`** when compatible with your work to reduce scratchpad-driven
441
+ breadth; mandatory gates (**`US-0048`**, **`US-0056`**, **`US-0069`**, **`US-0039`**) stay on.
442
+ - **Comparable** cache-read baselines require identical **`run_class_hash`**; otherwise
443
+ **`TOKEN_COST_RUN_CLASS_MISMATCH`** (no cross-plan gaming).
444
+ - Committed metrics: **`handoffs/token_cost_runs/<orchestrator_run_id>.md`**; link from
445
+ **`docs/engineering/state.md`** via **`token_cost_evidence_ref`**.
446
+ - Tooling: **`scripts/token_cost_lib.py`**, **`scripts/token_cost_compare.py`**,
447
+ **`python scripts/check_token_cost_parity.py --repo .`**.
433
448
  Use `python scripts/enforce-triad-hot-surface.py --check` before completing a
434
449
  phase that mutates them; use `--rollover` to archive oldest material into
435
450
  deterministic packs when over cap (DEC-0054).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "its-magic",
3
- "version": "0.1.2-35",
3
+ "version": "0.1.2-37",
4
4
  "description": "its-magic - AI dev team workflow for Cursor.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -12,6 +12,10 @@
12
12
  "installer.sh",
13
13
  "installer.py",
14
14
  "scripts/doc_profile_lib.py",
15
+ "scripts/intake_evidence_validate.py",
16
+ "scripts/intake_evidence_lib.py",
17
+ "scripts/intake_bug_routing_guard.py",
18
+ "scripts/check_intake_template_parity.py",
15
19
  "bin/its-magic.js",
16
20
  "bin/postinstall.js"
17
21
  ],
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Verify active vs template/scripts/ bytes match for DEC-0063 intake gate modules (BUG-0001)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Normative pairs: repo scripts/ (canonical dev) → template/scripts/ (packaged ship path).
11
+ INTAKE_TEMPLATE_PAIRS: tuple[tuple[str, str], ...] = (
12
+ ("scripts/intake_evidence_validate.py", "template/scripts/intake_evidence_validate.py"),
13
+ ("scripts/intake_evidence_lib.py", "template/scripts/intake_evidence_lib.py"),
14
+ ("scripts/intake_bug_routing_guard.py", "template/scripts/intake_bug_routing_guard.py"),
15
+ ("scripts/check_intake_template_parity.py", "template/scripts/check_intake_template_parity.py"),
16
+ )
17
+
18
+
19
+ def main() -> int:
20
+ p = argparse.ArgumentParser(description=__doc__)
21
+ p.add_argument(
22
+ "--repo",
23
+ type=Path,
24
+ default=Path(__file__).resolve().parent.parent,
25
+ help="Repository root",
26
+ )
27
+ args = p.parse_args()
28
+ root: Path = args.repo
29
+ failed = False
30
+ for rel_active, rel_tpl in INTAKE_TEMPLATE_PAIRS:
31
+ a = root / rel_active
32
+ t = root / rel_tpl
33
+ if not a.is_file() or not t.is_file():
34
+ print(f"[INTAKE_TEMPLATE_PARITY_ERROR] missing file: {rel_active} or {rel_tpl}")
35
+ failed = True
36
+ continue
37
+ ba = a.read_bytes()
38
+ bt = t.read_bytes()
39
+ if ba != bt:
40
+ print(
41
+ f"[INTAKE_TEMPLATE_PARITY_ERROR] mismatch: {rel_active} ({len(ba)}b) "
42
+ f"!= {rel_tpl} ({len(bt)}b)"
43
+ )
44
+ failed = True
45
+ if failed:
46
+ return 2
47
+ print("[INTAKE_TEMPLATE_PARITY_OK]")
48
+ return 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fail-closed guard: defect-shaped prose must not persist as US-xxxx without bug routing (DEC-0061 §5).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import re
10
+ import sys
11
+
12
+ # Strong defect signals (deterministic heuristic — PO still must set INTAKE_WORK_ITEM_KIND or /intake bug)
13
+ _REPRO = re.compile(
14
+ r"\b(steps\s+to\s+reproduce|steps_to_reproduce|repro\s+steps|reproduction\s+steps)\b",
15
+ re.IGNORECASE,
16
+ )
17
+ _DEFECT = re.compile(
18
+ r"\b(bug|regression|defect|crash|stack\s+trace|broken|throws\s+exception)\b",
19
+ re.IGNORECASE,
20
+ )
21
+
22
+
23
+ def prose_looks_like_defect(text: str) -> bool:
24
+ low = text.lower()
25
+ if not _DEFECT.search(low):
26
+ return False
27
+ if _REPRO.search(low):
28
+ return True
29
+ if "expected" in low and "actual" in low:
30
+ return True
31
+ return False
32
+
33
+
34
+ def main() -> int:
35
+ ap = argparse.ArgumentParser(
36
+ description="If work item kind is story but prose looks like a defect report, fail with INTAKE_BUG_ROUTING_REQUIRED."
37
+ )
38
+ ap.add_argument("--kind", choices=("story", "bug"), required=True)
39
+ ap.add_argument("--file", help="Path to prose file (title+summary)")
40
+ ap.add_argument("--stdin", action="store_true", help="Read prose from stdin")
41
+ args = ap.parse_args()
42
+
43
+ if args.kind == "bug":
44
+ print("[INTAKE_BUG_ROUTING_OK] kind=bug")
45
+ return 0
46
+
47
+ if args.stdin:
48
+ text = sys.stdin.read()
49
+ elif args.file:
50
+ text = open(args.file, encoding="utf-8").read()
51
+ else:
52
+ print("INTAKE_BUG_ROUTING_GUARD_ERROR: provide --file or --stdin", file=sys.stderr)
53
+ return 2
54
+
55
+ if prose_looks_like_defect(text):
56
+ print(
57
+ "INTAKE_BUG_ROUTING_REQUIRED: defect-shaped prose with INTAKE_WORK_ITEM_KIND=story "
58
+ "(set INTAKE_WORK_ITEM_KIND=bug and/or use `/intake bug` per DEC-0061 §5)",
59
+ file=sys.stderr,
60
+ )
61
+ return 3
62
+ print("[INTAKE_BUG_ROUTING_OK] kind=story")
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ raise SystemExit(main())
@@ -0,0 +1,399 @@
1
+ """
2
+ Deterministic intake evidence validation (US-0078 / DEC-0060 / R-0055).
3
+
4
+ Consumes a logical intake_evidence bundle (dict). PO workflows MUST run this
5
+ gate before mutating backlog/acceptance; failures are fail-closed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ IE_REF_RE = re.compile(r"^ie:([^:]+):(\d+):([0-9a-f]{16})$")
17
+
18
+ PACK_REQUIRED_KEYS: dict[str, tuple[str, ...]] = {
19
+ "first-intake-pack": (
20
+ "users_problem",
21
+ "runtime_target_environment",
22
+ "language_framework_runtime",
23
+ "architecture_preference",
24
+ "ui_design_expectations",
25
+ "security_compliance",
26
+ "non_functional_priorities",
27
+ "scope_timeline",
28
+ ),
29
+ "small-intake-pack": (
30
+ "outcome_success_criteria",
31
+ "impacted_components",
32
+ "constraints_compatibility_risks",
33
+ "required_tests_acceptance_checks",
34
+ "done_definition",
35
+ ),
36
+ }
37
+
38
+ ASSUMPTIONS_TOPIC_KEY = "assumptions_bundle"
39
+
40
+ SAFE_ASSUMPTION_LITERALS = frozenset(
41
+ {
42
+ "",
43
+ "(none)",
44
+ "none",
45
+ "no",
46
+ "false",
47
+ "n/a",
48
+ "na",
49
+ "n",
50
+ }
51
+ )
52
+
53
+ FALSE_CONFIRMATION_LITERALS = frozenset({"yes", "true", "confirmed"})
54
+
55
+
56
+ def canonical_json_sha256_16(obj: dict[str, Any]) -> str:
57
+ """Sorted-key compact JSON UTF-8 → first 16 hex chars of SHA-256 (DEC-0060)."""
58
+ blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
59
+ return hashlib.sha256(blob).hexdigest()[:16]
60
+
61
+
62
+ def build_ie_ref(
63
+ intake_run_id: str,
64
+ turn_index: int,
65
+ topic_key: str,
66
+ satisfied_by: str,
67
+ quoted_user_text: str,
68
+ ) -> str:
69
+ """Construct ie: ref per DEC-0060 §4."""
70
+ payload = {
71
+ "intake_run_id": intake_run_id,
72
+ "turn_index": int(turn_index),
73
+ "topic_key": topic_key,
74
+ "satisfied_by": satisfied_by,
75
+ "quoted_user_text": (quoted_user_text or "").strip(),
76
+ }
77
+ digest = canonical_json_sha256_16(payload)
78
+ return f"ie:{intake_run_id}:{int(turn_index)}:{digest}"
79
+
80
+
81
+ def parse_ie_ref(ref: str) -> tuple[str, int, str] | None:
82
+ if not ref or not isinstance(ref, str):
83
+ return None
84
+ m = IE_REF_RE.match(ref.strip())
85
+ if not m:
86
+ return None
87
+ return m.group(1), int(m.group(2)), m.group(3)
88
+
89
+
90
+ def verify_ie_ref(
91
+ ref: str,
92
+ *,
93
+ intake_run_id: str,
94
+ turn_index: int,
95
+ topic_key: str,
96
+ satisfied_by: str,
97
+ quoted_user_text: str,
98
+ ) -> bool:
99
+ parsed = parse_ie_ref(ref)
100
+ if not parsed:
101
+ return False
102
+ prun, pturn, phash = parsed
103
+ if prun != intake_run_id or pturn != int(turn_index):
104
+ return False
105
+ payload = {
106
+ "intake_run_id": intake_run_id,
107
+ "turn_index": int(turn_index),
108
+ "topic_key": topic_key,
109
+ "satisfied_by": satisfied_by,
110
+ "quoted_user_text": (quoted_user_text or "").strip(),
111
+ }
112
+ return canonical_json_sha256_16(payload) == phash
113
+
114
+
115
+ def _norm_assumption(s: str) -> str:
116
+ return (s or "").strip()
117
+
118
+
119
+ def assumption_literal_requires_confirmation_ref(value: str) -> bool:
120
+ """R-0055 rules 4–5 + DEC-0060: non-placeholder confirmations need evidence."""
121
+ v = _norm_assumption(value)
122
+ if not v:
123
+ return False
124
+ low = v.lower()
125
+ if low in SAFE_ASSUMPTION_LITERALS:
126
+ return False
127
+ if low in FALSE_CONFIRMATION_LITERALS:
128
+ return True
129
+ # Any other non-empty narrative still implies confirmation was recorded → needs ref
130
+ return True
131
+
132
+
133
+ @dataclass
134
+ class ValidationResult:
135
+ ok: bool
136
+ primary_codes: list[str] = field(default_factory=list)
137
+ missing_topics: list[str] = field(default_factory=list)
138
+ diagnostics: list[str] = field(default_factory=list)
139
+
140
+ def add_code(self, code: str) -> None:
141
+ if code not in self.primary_codes:
142
+ self.primary_codes.append(code)
143
+
144
+
145
+ def _topic_rows(bundle: dict[str, Any]) -> list[dict[str, Any]]:
146
+ raw = bundle.get("topic_coverage")
147
+ if raw is None:
148
+ return []
149
+ if not isinstance(raw, list):
150
+ return []
151
+ out: list[dict[str, Any]] = []
152
+ for row in raw:
153
+ if isinstance(row, dict):
154
+ out.append(row)
155
+ return out
156
+
157
+
158
+ def _asked_set(bundle: dict[str, Any]) -> set[str]:
159
+ at = bundle.get("asked_topics")
160
+ if at is None:
161
+ return set()
162
+ if isinstance(at, str):
163
+ parts = [p.strip() for p in at.replace("\n", ",").split(",")]
164
+ return {p for p in parts if p}
165
+ if isinstance(at, list):
166
+ return {str(x).strip() for x in at if str(x).strip()}
167
+ return set()
168
+
169
+
170
+ def _row_run_id(bundle: dict[str, Any], row: dict[str, Any]) -> str | None:
171
+ v = row.get("intake_run_id")
172
+ if v is not None and str(v).strip():
173
+ return str(v).strip()
174
+ v2 = bundle.get("intake_run_id")
175
+ if v2 is not None and str(v2).strip():
176
+ return str(v2).strip()
177
+ return None
178
+
179
+
180
+ def _row_turn(row: dict[str, Any]) -> int | None:
181
+ t = row.get("turn_index")
182
+ if t is None:
183
+ return None
184
+ try:
185
+ return int(t)
186
+ except (TypeError, ValueError):
187
+ return None
188
+
189
+
190
+ def validate_intake_evidence(
191
+ bundle: dict[str, Any],
192
+ *,
193
+ intake_guided_mode: int | None = None,
194
+ ) -> ValidationResult:
195
+ """
196
+ Validate logical intake_evidence. intake_guided_mode is accepted for API parity
197
+ (AC-5/AC-6): validation rules do not branch on mode — same pipeline for {0,1}.
198
+ """
199
+ _ = intake_guided_mode # explicit no-op for parity contract
200
+ res = ValidationResult(ok=True)
201
+
202
+ pack = (bundle.get("selected_pack") or "").strip()
203
+ if pack not in PACK_REQUIRED_KEYS:
204
+ res.ok = False
205
+ res.add_code("INTAKE_REQUIRED_PACK_INCOMPLETE")
206
+ res.diagnostics.append(
207
+ f"Remediation: set selected_pack to one of {sorted(PACK_REQUIRED_KEYS.keys())!r}; "
208
+ f"unknown or empty pack is not allowed before persistence."
209
+ )
210
+ res.add_code("INTAKE_PERSISTENCE_BLOCKED")
211
+ return res
212
+
213
+ required = list(PACK_REQUIRED_KEYS[pack])
214
+ rows = _topic_rows(bundle)
215
+ by_key: dict[str, dict[str, Any]] = {}
216
+ for row in rows:
217
+ k = (row.get("topic_key") or "").strip()
218
+ if k:
219
+ by_key[k] = row
220
+
221
+ missing_cov: list[str] = []
222
+ for k in required:
223
+ row = by_key.get(k)
224
+ if not row:
225
+ missing_cov.append(k)
226
+ continue
227
+
228
+ ref = (row.get("ref") or "").strip()
229
+ sat = (row.get("satisfied_by") or "").strip()
230
+ qtxt = row.get("quoted_user_text")
231
+ if qtxt is None:
232
+ qtxt = ""
233
+ qtxt = str(qtxt)
234
+
235
+ if sat not in ("answer_ref", "assumption_confirmation_ref"):
236
+ res.ok = False
237
+ res.diagnostics.append(
238
+ f"Remediation: topic {k!r} must set satisfied_by to "
239
+ f"'answer_ref' or 'assumption_confirmation_ref' (got {sat!r})."
240
+ )
241
+ missing_cov.append(k)
242
+ continue
243
+
244
+ irid = _row_run_id(bundle, row)
245
+ tit = _row_turn(row)
246
+ if irid is None or tit is None:
247
+ res.ok = False
248
+ res.diagnostics.append(
249
+ f"Remediation: topic {k!r} needs intake_run_id and turn_index (row or bundle) "
250
+ f"for ie: ref verification."
251
+ )
252
+ missing_cov.append(k)
253
+ continue
254
+
255
+ if not ref or not verify_ie_ref(
256
+ ref,
257
+ intake_run_id=irid,
258
+ turn_index=tit,
259
+ topic_key=k,
260
+ satisfied_by=sat,
261
+ quoted_user_text=qtxt,
262
+ ):
263
+ res.ok = False
264
+ res.diagnostics.append(
265
+ f"Remediation: topic {k!r} ref is malformed or hash mismatch — rebuild ie: ref "
266
+ f"with DEC-0060 canonical JSON (sorted keys) and quoted_user_text."
267
+ )
268
+ missing_cov.append(k)
269
+
270
+ if missing_cov:
271
+ res.ok = False
272
+ res.missing_topics = sorted(set(missing_cov))
273
+ res.add_code("INTAKE_REQUIRED_TOPIC_MISSING")
274
+ if len(res.missing_topics) > 1:
275
+ res.add_code("INTAKE_REQUIRED_PACK_INCOMPLETE")
276
+ res.diagnostics.append(
277
+ "Remediation: supply complete topic_coverage with valid ie: refs for: "
278
+ + ", ".join(res.missing_topics)
279
+ )
280
+
281
+ asked = _asked_set(bundle)
282
+ for k in required:
283
+ if k not in by_key:
284
+ continue
285
+ if k not in asked:
286
+ res.ok = False
287
+ res.add_code("INTAKE_REQUIRED_TOPIC_MISSING")
288
+ res.diagnostics.append(
289
+ f"Remediation: add {k!r} to asked_topics — covered topics must have been "
290
+ f"prompted in-session (R-0055 asked-vs-covered; DEC-0060)."
291
+ )
292
+ if k not in res.missing_topics:
293
+ res.missing_topics.append(k)
294
+ res.missing_topics = sorted(set(res.missing_topics))
295
+
296
+ ac = bundle.get("assumptions_confirmed")
297
+ ac_str = ac if isinstance(ac, str) else ("(none)" if ac is None else str(ac))
298
+
299
+ low = _norm_assumption(ac_str).lower()
300
+ if low in FALSE_CONFIRMATION_LITERALS and not (bundle.get("assumption_confirmation_ref") or "").strip():
301
+ res.ok = False
302
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
303
+ res.diagnostics.append(
304
+ "Remediation: literal yes/true/confirmed is rejected without "
305
+ "assumption_confirmation_ref (R-0055 rule 5)."
306
+ )
307
+
308
+ if assumption_literal_requires_confirmation_ref(ac_str):
309
+ aref = (bundle.get("assumption_confirmation_ref") or "").strip()
310
+ aquote = str(bundle.get("assumption_confirmation_quoted") or "")
311
+ irid = bundle.get("assumption_confirmation_intake_run_id")
312
+ tit = bundle.get("assumption_confirmation_turn_index")
313
+ if not aref or irid is None or tit is None:
314
+ res.ok = False
315
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
316
+ res.diagnostics.append(
317
+ "Remediation: affirmative assumptions_confirmed requires assumption_confirmation_ref "
318
+ "plus assumption_confirmation_intake_run_id, assumption_confirmation_turn_index, "
319
+ "and assumption_confirmation_quoted for ie: binding."
320
+ )
321
+ elif not verify_ie_ref(
322
+ aref,
323
+ intake_run_id=str(irid),
324
+ turn_index=int(tit),
325
+ topic_key=ASSUMPTIONS_TOPIC_KEY,
326
+ satisfied_by="assumption_confirmation_ref",
327
+ quoted_user_text=aquote,
328
+ ):
329
+ res.ok = False
330
+ res.add_code("INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED")
331
+ res.diagnostics.append(
332
+ "Remediation: assumption_confirmation_ref is invalid or does not match "
333
+ "quoted affirmative user text / run metadata."
334
+ )
335
+
336
+ if not res.ok and "INTAKE_PERSISTENCE_BLOCKED" not in res.primary_codes:
337
+ res.add_code("INTAKE_PERSISTENCE_BLOCKED")
338
+
339
+ return res
340
+
341
+
342
+ def format_blocked_message(result: ValidationResult) -> str:
343
+ lines = [
344
+ "INTAKE_PERSISTENCE_BLOCKED",
345
+ "primary_codes=" + ",".join(result.primary_codes),
346
+ ]
347
+ if result.missing_topics:
348
+ lines.append("missing_topics=" + ",".join(result.missing_topics))
349
+ lines.extend(result.diagnostics)
350
+ return "\n".join(lines)
351
+
352
+
353
+ def self_test() -> None:
354
+ """Minimal sanity checks for --self-test / installer wiring."""
355
+ rid = "selftest-run"
356
+ ref = build_ie_ref(rid, 0, "outcome_success_criteria", "answer_ref", "ok")
357
+ assert parse_ie_ref(ref) is not None
358
+ assert verify_ie_ref(
359
+ ref,
360
+ intake_run_id=rid,
361
+ turn_index=0,
362
+ topic_key="outcome_success_criteria",
363
+ satisfied_by="answer_ref",
364
+ quoted_user_text="ok",
365
+ )
366
+ assert not verify_ie_ref(
367
+ ref,
368
+ intake_run_id=rid,
369
+ turn_index=0,
370
+ topic_key="outcome_success_criteria",
371
+ satisfied_by="answer_ref",
372
+ quoted_user_text="tampered",
373
+ )
374
+ # Mode argument must not change outcome (AC-6 / AC-5 parity)
375
+ small = PACK_REQUIRED_KEYS["small-intake-pack"]
376
+ rows = []
377
+ for i, key in enumerate(small):
378
+ rows.append(
379
+ {
380
+ "topic_key": key,
381
+ "satisfied_by": "answer_ref",
382
+ "quoted_user_text": f"a{i}",
383
+ "intake_run_id": rid,
384
+ "turn_index": i,
385
+ "ref": build_ie_ref(rid, i, key, "answer_ref", f"a{i}"),
386
+ }
387
+ )
388
+ bundle = {
389
+ "selected_pack": "small-intake-pack",
390
+ "intake_run_id": rid,
391
+ "asked_topics": list(small),
392
+ "missing_topics": [],
393
+ "assumptions_confirmed": "(none)",
394
+ "topic_coverage": rows,
395
+ }
396
+ r0 = validate_intake_evidence(bundle, intake_guided_mode=0)
397
+ r1 = validate_intake_evidence(bundle, intake_guided_mode=1)
398
+ assert r0.ok and r1.ok
399
+ assert r0.primary_codes == r1.primary_codes
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validate intake_evidence JSON bundles (US-0078 / DEC-0060).
4
+
5
+ Used by PO workflow preflight and CI fixtures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ _REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
17
+ if _SCRIPT_DIR not in sys.path:
18
+ sys.path.insert(0, _SCRIPT_DIR)
19
+
20
+ import intake_evidence_lib # noqa: E402
21
+
22
+
23
+ def main() -> int:
24
+ p = argparse.ArgumentParser(description="Validate intake_evidence JSON (US-0078).")
25
+ p.add_argument("--file", help="Path to JSON file containing one intake_evidence object.")
26
+ p.add_argument(
27
+ "--stdin",
28
+ action="store_true",
29
+ help="Read JSON object from stdin.",
30
+ )
31
+ p.add_argument(
32
+ "--self-test",
33
+ action="store_true",
34
+ help="Run library sanity checks and exit.",
35
+ )
36
+ args = p.parse_args()
37
+
38
+ if args.self_test:
39
+ intake_evidence_lib.self_test()
40
+ print("[INTAKE_EVIDENCE_SELF_TEST_OK]")
41
+ return 0
42
+
43
+ raw = ""
44
+ if args.file:
45
+ with open(os.path.abspath(args.file), encoding="utf-8") as f:
46
+ raw = f.read()
47
+ elif args.stdin:
48
+ raw = sys.stdin.read()
49
+ else:
50
+ print("error: specify --file, --stdin, or --self-test", file=sys.stderr)
51
+ return 2
52
+
53
+ try:
54
+ bundle = json.loads(raw)
55
+ except json.JSONDecodeError as e:
56
+ print(f"error: invalid JSON: {e}", file=sys.stderr)
57
+ return 2
58
+
59
+ if not isinstance(bundle, dict):
60
+ print("error: root must be a JSON object", file=sys.stderr)
61
+ return 2
62
+
63
+ r = intake_evidence_lib.validate_intake_evidence(bundle)
64
+ if r.ok:
65
+ print("[INTAKE_EVIDENCE_VALIDATION_OK]")
66
+ return 0
67
+
68
+ print(intake_evidence_lib.format_blocked_message(r), file=sys.stderr)
69
+ return 1
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())