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 +15 -0
- package/package.json +5 -1
- package/scripts/check_intake_template_parity.py +52 -0
- package/scripts/intake_bug_routing_guard.py +67 -0
- package/scripts/intake_evidence_lib.py +399 -0
- package/scripts/intake_evidence_validate.py +73 -0
- package/template/.cursor/commands/auto.md +86 -547
- package/template/.cursor/commands/execute.md +10 -0
- package/template/README.md +15 -0
- package/template/docs/engineering/auto-orchestration-reference.md +646 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
- package/template/docs/engineering/runbook.md +16 -0
- package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
- package/template/handoffs/token_cost_runs/README.md +26 -0
- package/template/scripts/check_intake_template_parity.py +52 -0
- package/template/scripts/check_token_cost_parity.py +69 -0
- package/template/scripts/intake_bug_routing_guard.py +67 -0
- package/template/scripts/intake_evidence_lib.py +399 -0
- package/template/scripts/intake_evidence_validate.py +73 -0
- package/template/scripts/token_cost_compare.py +40 -0
- package/template/scripts/token_cost_lib.py +108 -0
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-
|
|
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())
|