its-magic 0.1.2-37 → 0.1.2-38
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/installer.ps1 +20 -0
- package/installer.py +66 -2
- package/installer.sh +19 -0
- package/package.json +2 -1
- package/scripts/intake_evidence_lib.py +314 -10
- package/scripts/intake_evidence_validate.py +2 -2
- package/scripts/materialize_codebase_map.py +184 -0
- package/template/.cursor/agents/po.mdc +19 -0
- package/template/.cursor/commands/architecture.md +12 -0
- package/template/.cursor/commands/ask.md +11 -0
- package/template/.cursor/commands/intake.md +35 -7
- package/template/.cursor/commands/map-codebase.md +18 -1
- package/template/.cursor/commands/refresh-context.md +7 -0
- package/template/.cursor/rules/core.mdc +5 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +17 -0
- package/template/docs/engineering/runbook.md +76 -2
- package/template/scripts/enforce-triad-hot-surface.py +626 -0
- package/template/scripts/intake_evidence_lib.py +314 -10
- package/template/scripts/intake_evidence_validate.py +2 -2
- package/template/scripts/materialize_codebase_map.py +184 -0
package/installer.ps1
CHANGED
|
@@ -405,6 +405,24 @@ function Invoke-ScratchpadPostinstall {
|
|
|
405
405
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
function Invoke-InstallCompletenessValidation {
|
|
409
|
+
param(
|
|
410
|
+
[string]$TargetRoot
|
|
411
|
+
)
|
|
412
|
+
$installerPy = Join-Path $scriptDir "installer.py"
|
|
413
|
+
if (-not (Test-Path $installerPy -PathType Leaf)) {
|
|
414
|
+
Write-Host "[INSTALL_COMPLETENESS_FAILED] installer.py missing next to installer.ps1."
|
|
415
|
+
exit 1
|
|
416
|
+
}
|
|
417
|
+
$py = Get-Command python -ErrorAction SilentlyContinue
|
|
418
|
+
if (-not $py) {
|
|
419
|
+
Write-Host "[INSTALL_COMPLETENESS_FAILED] PYTHON_NOT_FOUND: Python is required for deterministic installer completeness validation."
|
|
420
|
+
exit 1
|
|
421
|
+
}
|
|
422
|
+
& python $installerPy --validate-install-completeness --target $TargetRoot
|
|
423
|
+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
424
|
+
}
|
|
425
|
+
|
|
408
426
|
function Show-ItsMagicBanner([switch]$IncludeInstallMessage) {
|
|
409
427
|
$prev = [Console]::OutputEncoding
|
|
410
428
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
@@ -643,6 +661,7 @@ if ($mode -eq "upgrade") {
|
|
|
643
661
|
}
|
|
644
662
|
|
|
645
663
|
Invoke-ScratchpadPostinstall -TargetRoot $targetRoot -Mode "upgrade"
|
|
664
|
+
Invoke-InstallCompletenessValidation -TargetRoot $targetRoot
|
|
646
665
|
|
|
647
666
|
Write-InstalledVersion $targetRoot $appVersion
|
|
648
667
|
Sync-RootReadmeToItsMagic $targetRoot | Out-Null
|
|
@@ -724,6 +743,7 @@ foreach ($rel in $files) {
|
|
|
724
743
|
}
|
|
725
744
|
|
|
726
745
|
Invoke-ScratchpadPostinstall -TargetRoot $targetRoot -Mode $mode
|
|
746
|
+
Invoke-InstallCompletenessValidation -TargetRoot $targetRoot
|
|
727
747
|
|
|
728
748
|
Write-InstalledVersion $targetRoot $appVersion
|
|
729
749
|
Sync-RootReadmeToItsMagic $targetRoot | Out-Null
|
package/installer.py
CHANGED
|
@@ -11,6 +11,7 @@ from datetime import datetime
|
|
|
11
11
|
|
|
12
12
|
REPO_URL = "https://github.com/fl0wm0ti0n/its-magic"
|
|
13
13
|
MANIFEST_RELATIVE_PATH = os.path.join("docs", "engineering", "context", "installer-owned-paths.manifest")
|
|
14
|
+
MANIFEST_REQUIRED_SCRIPTS_SECTION = "required_install_script_paths"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def normalize(path):
|
|
@@ -67,12 +68,42 @@ def load_ownership_manifest(source_root, script_dir):
|
|
|
67
68
|
continue
|
|
68
69
|
install_paths = read_manifest_paths(path, "install_include_paths")
|
|
69
70
|
clean_paths = read_manifest_paths(path, "clean_paths")
|
|
71
|
+
required_script_paths = read_manifest_paths(path, MANIFEST_REQUIRED_SCRIPTS_SECTION)
|
|
70
72
|
if not install_paths or not clean_paths:
|
|
71
73
|
raise RuntimeError(f"[INSTALL_MANIFEST_ERROR] {path} is missing required sections or entries.")
|
|
72
|
-
|
|
74
|
+
if not required_script_paths:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"[INSTALL_MANIFEST_ERROR] {path} is missing [{MANIFEST_REQUIRED_SCRIPTS_SECTION}] entries."
|
|
77
|
+
)
|
|
78
|
+
return install_paths, clean_paths, required_script_paths, path
|
|
73
79
|
raise RuntimeError("[INSTALL_SOURCE_ERROR] installer-owned-paths.manifest not found. Reinstall its-magic package.")
|
|
74
80
|
|
|
75
81
|
|
|
82
|
+
def validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
83
|
+
missing_paths = []
|
|
84
|
+
for rel in sorted(set(required_script_paths)):
|
|
85
|
+
src = os.path.join(source_root, rel)
|
|
86
|
+
dst = os.path.join(target_root, rel)
|
|
87
|
+
if not os.path.isfile(src) or not os.path.isfile(dst):
|
|
88
|
+
missing_paths.append(rel.replace("\\", "/"))
|
|
89
|
+
if not missing_paths:
|
|
90
|
+
return True
|
|
91
|
+
print(
|
|
92
|
+
"[INSTALL_COMPLETENESS_FAILED] Required installer scripts are missing after "
|
|
93
|
+
"copy/classification invariant check."
|
|
94
|
+
)
|
|
95
|
+
for rel in missing_paths:
|
|
96
|
+
print(f"[INSTALL_REQUIRED_SCRIPT_MISSING:{rel}]")
|
|
97
|
+
print(
|
|
98
|
+
"Fix: update manifest parity and required-script inventory at "
|
|
99
|
+
f"{MANIFEST_RELATIVE_PATH} (section [{MANIFEST_REQUIRED_SCRIPTS_SECTION}]), "
|
|
100
|
+
"ensure each listed script exists in template/scripts and clean-path ownership, "
|
|
101
|
+
"then rerun installer missing/upgrade."
|
|
102
|
+
)
|
|
103
|
+
print(f"Manifest source: {manifest_path}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
76
107
|
def ensure_parent(path):
|
|
77
108
|
parent = os.path.dirname(path)
|
|
78
109
|
if parent and not os.path.isdir(parent):
|
|
@@ -684,6 +715,12 @@ def main():
|
|
|
684
715
|
action="store_true",
|
|
685
716
|
help=argparse.SUPPRESS,
|
|
686
717
|
)
|
|
718
|
+
parser.add_argument("--source-root", help=argparse.SUPPRESS)
|
|
719
|
+
parser.add_argument(
|
|
720
|
+
"--validate-install-completeness",
|
|
721
|
+
action="store_true",
|
|
722
|
+
help=argparse.SUPPRESS,
|
|
723
|
+
)
|
|
687
724
|
args = parser.parse_args()
|
|
688
725
|
|
|
689
726
|
if len(sys.argv) == 1 or args.help:
|
|
@@ -694,6 +731,9 @@ def main():
|
|
|
694
731
|
print(f"its-magic v{version}")
|
|
695
732
|
return 0
|
|
696
733
|
|
|
734
|
+
if args.source_root:
|
|
735
|
+
source_root = normalize(args.source_root)
|
|
736
|
+
|
|
697
737
|
if args.scratchpad_postinstall:
|
|
698
738
|
target_root = normalize(args.target) if args.target else normalize(".")
|
|
699
739
|
mode = args.mode or "missing"
|
|
@@ -712,11 +752,31 @@ def main():
|
|
|
712
752
|
ok = run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True)
|
|
713
753
|
return 0 if ok else 1
|
|
714
754
|
|
|
755
|
+
if args.validate_install_completeness:
|
|
756
|
+
target_root = normalize(args.target) if args.target else normalize(".")
|
|
757
|
+
if not os.path.isdir(target_root):
|
|
758
|
+
print(f"[INSTALL_COMPLETENESS_FAILED] target directory missing: {target_root}")
|
|
759
|
+
return 1
|
|
760
|
+
if not os.path.isdir(source_root):
|
|
761
|
+
print("[INSTALL_SOURCE_ERROR] template directory is missing. Reinstall its-magic package.")
|
|
762
|
+
return 1
|
|
763
|
+
try:
|
|
764
|
+
_install_paths, _clean_paths, required_script_paths, manifest_path = load_ownership_manifest(
|
|
765
|
+
source_root, script_dir
|
|
766
|
+
)
|
|
767
|
+
except RuntimeError as exc:
|
|
768
|
+
print(str(exc))
|
|
769
|
+
return 1
|
|
770
|
+
ok = validate_install_completeness(target_root, source_root, required_script_paths, manifest_path)
|
|
771
|
+
return 0 if ok else 1
|
|
772
|
+
|
|
715
773
|
if not os.path.isdir(source_root):
|
|
716
774
|
print("[INSTALL_SOURCE_ERROR] template directory is missing. Reinstall its-magic package.")
|
|
717
775
|
return 1
|
|
718
776
|
try:
|
|
719
|
-
include_paths, clean_paths = load_ownership_manifest(
|
|
777
|
+
include_paths, clean_paths, required_script_paths, manifest_path = load_ownership_manifest(
|
|
778
|
+
source_root, script_dir
|
|
779
|
+
)
|
|
720
780
|
except RuntimeError as exc:
|
|
721
781
|
print(str(exc))
|
|
722
782
|
return 1
|
|
@@ -818,6 +878,8 @@ def main():
|
|
|
818
878
|
|
|
819
879
|
if not run_scratchpad_postinstall(target_root, source_root, "upgrade", print_ok=True):
|
|
820
880
|
return 1
|
|
881
|
+
if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
882
|
+
return 1
|
|
821
883
|
|
|
822
884
|
write_installed_version(target_root, version)
|
|
823
885
|
sync_root_readme_to_its_magic(target_root)
|
|
@@ -896,6 +958,8 @@ def main():
|
|
|
896
958
|
|
|
897
959
|
if not run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
|
|
898
960
|
return 1
|
|
961
|
+
if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
962
|
+
return 1
|
|
899
963
|
|
|
900
964
|
write_installed_version(target_root, version)
|
|
901
965
|
sync_root_readme_to_its_magic(target_root)
|
package/installer.sh
CHANGED
|
@@ -151,6 +151,23 @@ scratchpad_postinstall() {
|
|
|
151
151
|
fi
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
validate_install_completeness() {
|
|
155
|
+
target_root="$1"
|
|
156
|
+
installer_py="$SCRIPT_DIR/installer.py"
|
|
157
|
+
if [ ! -f "$installer_py" ]; then
|
|
158
|
+
printf "%s\n" "[INSTALL_COMPLETENESS_FAILED] installer.py missing next to installer.sh."
|
|
159
|
+
exit 1
|
|
160
|
+
fi
|
|
161
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
162
|
+
python3 "$installer_py" --validate-install-completeness --target "$target_root" || exit $?
|
|
163
|
+
elif command -v python >/dev/null 2>&1; then
|
|
164
|
+
python "$installer_py" --validate-install-completeness --target "$target_root" || exit $?
|
|
165
|
+
else
|
|
166
|
+
printf "%s\n" "[INSTALL_COMPLETENESS_FAILED] PYTHON_NOT_FOUND: Python is required for deterministic installer completeness validation."
|
|
167
|
+
exit 1
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
154
171
|
classify_file() {
|
|
155
172
|
rel="$1"
|
|
156
173
|
case "$rel" in
|
|
@@ -535,6 +552,7 @@ if [ "$MODE" = "upgrade" ]; then
|
|
|
535
552
|
done
|
|
536
553
|
|
|
537
554
|
scratchpad_postinstall "$TARGET_ROOT" "upgrade"
|
|
555
|
+
validate_install_completeness "$TARGET_ROOT"
|
|
538
556
|
|
|
539
557
|
write_installed_version "$TARGET_ROOT" "$APP_VERSION"
|
|
540
558
|
sync_root_readme_to_its_magic "$TARGET_ROOT" || true
|
|
@@ -606,6 +624,7 @@ for rel in $FILES; do
|
|
|
606
624
|
done
|
|
607
625
|
|
|
608
626
|
scratchpad_postinstall "$TARGET_ROOT" "$MODE"
|
|
627
|
+
validate_install_completeness "$TARGET_ROOT"
|
|
609
628
|
|
|
610
629
|
write_installed_version "$TARGET_ROOT" "$APP_VERSION"
|
|
611
630
|
sync_root_readme_to_its_magic "$TARGET_ROOT" || true
|
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-38",
|
|
4
4
|
"description": "its-magic - AI dev team workflow for Cursor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"scripts/intake_evidence_lib.py",
|
|
17
17
|
"scripts/intake_bug_routing_guard.py",
|
|
18
18
|
"scripts/check_intake_template_parity.py",
|
|
19
|
+
"scripts/materialize_codebase_map.py",
|
|
19
20
|
"bin/its-magic.js",
|
|
20
21
|
"bin/postinstall.js"
|
|
21
22
|
],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Deterministic intake evidence validation
|
|
2
|
+
Deterministic intake evidence validation
|
|
3
|
+
(US-0078 / US-0083 / DEC-0060 / DEC-0067 / R-0055).
|
|
3
4
|
|
|
4
5
|
Consumes a logical intake_evidence bundle (dict). PO workflows MUST run this
|
|
5
6
|
gate before mutating backlog/acceptance; failures are fail-closed.
|
|
@@ -14,6 +15,7 @@ from dataclasses import dataclass, field
|
|
|
14
15
|
from typing import Any
|
|
15
16
|
|
|
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}$")
|
|
17
19
|
|
|
18
20
|
PACK_REQUIRED_KEYS: dict[str, tuple[str, ...]] = {
|
|
19
21
|
"first-intake-pack": (
|
|
@@ -51,6 +53,8 @@ SAFE_ASSUMPTION_LITERALS = frozenset(
|
|
|
51
53
|
)
|
|
52
54
|
|
|
53
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"})
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
def canonical_json_sha256_16(obj: dict[str, Any]) -> str:
|
|
@@ -187,6 +191,197 @@ def _row_turn(row: dict[str, Any]) -> int | None:
|
|
|
187
191
|
return None
|
|
188
192
|
|
|
189
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 _candidate_story_ids(bundle: dict[str, Any]) -> set[str]:
|
|
206
|
+
out: set[str] = set()
|
|
207
|
+
|
|
208
|
+
raw_ids = bundle.get("candidate_story_ids")
|
|
209
|
+
if isinstance(raw_ids, list):
|
|
210
|
+
for sid in raw_ids:
|
|
211
|
+
sv = str(sid).strip()
|
|
212
|
+
if sv:
|
|
213
|
+
out.add(sv)
|
|
214
|
+
|
|
215
|
+
raw_story_ids = bundle.get("story_ids")
|
|
216
|
+
if isinstance(raw_story_ids, list):
|
|
217
|
+
for sid in raw_story_ids:
|
|
218
|
+
sv = str(sid).strip()
|
|
219
|
+
if sv:
|
|
220
|
+
out.add(sv)
|
|
221
|
+
|
|
222
|
+
raw_story_map = bundle.get("story_map")
|
|
223
|
+
if isinstance(raw_story_map, list):
|
|
224
|
+
for row in raw_story_map:
|
|
225
|
+
if not isinstance(row, dict):
|
|
226
|
+
continue
|
|
227
|
+
sv = str(row.get("story_id") or "").strip()
|
|
228
|
+
if sv:
|
|
229
|
+
out.add(sv)
|
|
230
|
+
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _validate_plan_coverage_contract(bundle: dict[str, Any], res: ValidationResult) -> None:
|
|
235
|
+
inventory_raw = bundle.get("plan_area_inventory")
|
|
236
|
+
coverage_raw = bundle.get("plan_area_coverage")
|
|
237
|
+
coverage_complete_raw = bundle.get("coverage_complete")
|
|
238
|
+
candidate_story_ids = _candidate_story_ids(bundle)
|
|
239
|
+
|
|
240
|
+
inventory_rows = inventory_raw if isinstance(inventory_raw, list) else []
|
|
241
|
+
coverage_rows = coverage_raw if isinstance(coverage_raw, list) else []
|
|
242
|
+
|
|
243
|
+
inventory_ids: list[str] = []
|
|
244
|
+
coverage_by_id: dict[str, dict[str, Any]] = {}
|
|
245
|
+
|
|
246
|
+
coverage_missing = False
|
|
247
|
+
id_invalid = False
|
|
248
|
+
contract_invalid = False
|
|
249
|
+
deferred_ref_missing = False
|
|
250
|
+
|
|
251
|
+
if not inventory_rows:
|
|
252
|
+
coverage_missing = True
|
|
253
|
+
res.diagnostics.append(
|
|
254
|
+
"Remediation: plan_area_inventory must be a non-empty list for first/new/broad intake."
|
|
255
|
+
)
|
|
256
|
+
if not coverage_rows:
|
|
257
|
+
coverage_missing = True
|
|
258
|
+
res.diagnostics.append(
|
|
259
|
+
"Remediation: plan_area_coverage must be a non-empty list for first/new/broad intake."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
seen_inventory_ids: set[str] = set()
|
|
263
|
+
for row in inventory_rows:
|
|
264
|
+
if not isinstance(row, dict):
|
|
265
|
+
id_invalid = True
|
|
266
|
+
continue
|
|
267
|
+
plan_area_id = str(row.get("plan_area_id") or "").strip()
|
|
268
|
+
if not PLAN_AREA_ID_RE.match(plan_area_id):
|
|
269
|
+
id_invalid = True
|
|
270
|
+
continue
|
|
271
|
+
if plan_area_id in seen_inventory_ids:
|
|
272
|
+
id_invalid = True
|
|
273
|
+
continue
|
|
274
|
+
seen_inventory_ids.add(plan_area_id)
|
|
275
|
+
inventory_ids.append(plan_area_id)
|
|
276
|
+
|
|
277
|
+
for row in coverage_rows:
|
|
278
|
+
if not isinstance(row, dict):
|
|
279
|
+
id_invalid = True
|
|
280
|
+
continue
|
|
281
|
+
plan_area_id = str(row.get("plan_area_id") or "").strip()
|
|
282
|
+
if not PLAN_AREA_ID_RE.match(plan_area_id):
|
|
283
|
+
id_invalid = True
|
|
284
|
+
continue
|
|
285
|
+
if plan_area_id in coverage_by_id:
|
|
286
|
+
id_invalid = True
|
|
287
|
+
continue
|
|
288
|
+
coverage_by_id[plan_area_id] = row
|
|
289
|
+
|
|
290
|
+
inventory_id_set = set(inventory_ids)
|
|
291
|
+
coverage_id_set = set(coverage_by_id.keys())
|
|
292
|
+
|
|
293
|
+
if inventory_id_set != coverage_id_set:
|
|
294
|
+
coverage_missing = True
|
|
295
|
+
missing_ids = sorted(inventory_id_set - coverage_id_set)
|
|
296
|
+
extra_ids = sorted(coverage_id_set - inventory_id_set)
|
|
297
|
+
if missing_ids:
|
|
298
|
+
res.diagnostics.append(
|
|
299
|
+
"Remediation: add plan_area_coverage rows for uncovered plan_area_id values: "
|
|
300
|
+
+ ", ".join(missing_ids)
|
|
301
|
+
)
|
|
302
|
+
if extra_ids:
|
|
303
|
+
contract_invalid = True
|
|
304
|
+
res.diagnostics.append(
|
|
305
|
+
"Remediation: remove unknown plan_area_coverage plan_area_id values not in plan_area_inventory: "
|
|
306
|
+
+ ", ".join(extra_ids)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
for plan_area_id in sorted(inventory_id_set & coverage_id_set):
|
|
310
|
+
row = coverage_by_id[plan_area_id]
|
|
311
|
+
story_ids_raw = row.get("story_ids")
|
|
312
|
+
deferred_ref = str(row.get("deferred_ref") or "").strip()
|
|
313
|
+
|
|
314
|
+
story_ids: list[str] = []
|
|
315
|
+
if isinstance(story_ids_raw, list):
|
|
316
|
+
for sid in story_ids_raw:
|
|
317
|
+
sv = str(sid).strip()
|
|
318
|
+
if sv:
|
|
319
|
+
story_ids.append(sv)
|
|
320
|
+
has_story_ids = bool(story_ids)
|
|
321
|
+
has_deferred_ref = bool(deferred_ref)
|
|
322
|
+
|
|
323
|
+
if has_story_ids == has_deferred_ref:
|
|
324
|
+
contract_invalid = True
|
|
325
|
+
res.diagnostics.append(
|
|
326
|
+
"Remediation: each plan_area_coverage row must set exactly one mapping path "
|
|
327
|
+
"(story_ids xor deferred_ref) for plan_area_id "
|
|
328
|
+
+ repr(plan_area_id)
|
|
329
|
+
+ "."
|
|
330
|
+
)
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
if has_story_ids:
|
|
334
|
+
if candidate_story_ids:
|
|
335
|
+
unknown_story_ids = sorted({sid for sid in story_ids if sid not in candidate_story_ids})
|
|
336
|
+
if unknown_story_ids:
|
|
337
|
+
contract_invalid = True
|
|
338
|
+
res.diagnostics.append(
|
|
339
|
+
"Remediation: plan_area_id "
|
|
340
|
+
+ repr(plan_area_id)
|
|
341
|
+
+ " references unknown story_ids not present in candidate story set: "
|
|
342
|
+
+ ", ".join(unknown_story_ids)
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
deferred_reason = str(row.get("deferred_reason") or "").strip()
|
|
346
|
+
if not deferred_reason:
|
|
347
|
+
deferred_ref_missing = True
|
|
348
|
+
res.diagnostics.append(
|
|
349
|
+
"Remediation: deferred mapping for plan_area_id "
|
|
350
|
+
+ repr(plan_area_id)
|
|
351
|
+
+ " requires both deferred_ref and deferred_reason."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
derived_coverage_complete = not (coverage_missing or id_invalid or contract_invalid or deferred_ref_missing)
|
|
355
|
+
if coverage_complete_raw is not True:
|
|
356
|
+
contract_invalid = True
|
|
357
|
+
res.diagnostics.append(
|
|
358
|
+
"Remediation: set coverage_complete=true only after plan_area_inventory and "
|
|
359
|
+
"plan_area_coverage pass deterministic validation."
|
|
360
|
+
)
|
|
361
|
+
if bool(coverage_complete_raw) != derived_coverage_complete:
|
|
362
|
+
contract_invalid = True
|
|
363
|
+
res.diagnostics.append(
|
|
364
|
+
"Remediation: coverage_complete must match derived contract result from "
|
|
365
|
+
"plan_area_inventory/plan_area_coverage validation."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if coverage_missing:
|
|
369
|
+
res.ok = False
|
|
370
|
+
res.add_code("INTAKE_PLAN_COVERAGE_MISSING")
|
|
371
|
+
if id_invalid:
|
|
372
|
+
res.ok = False
|
|
373
|
+
res.add_code("INTAKE_PLAN_AREA_ID_INVALID")
|
|
374
|
+
res.diagnostics.append(
|
|
375
|
+
"Remediation: plan_area_id must be unique and match ^[a-z0-9][a-z0-9_-]{1,63}$ in inventory and coverage."
|
|
376
|
+
)
|
|
377
|
+
if deferred_ref_missing:
|
|
378
|
+
res.ok = False
|
|
379
|
+
res.add_code("INTAKE_PLAN_DEFERRED_REF_MISSING")
|
|
380
|
+
if contract_invalid:
|
|
381
|
+
res.ok = False
|
|
382
|
+
res.add_code("INTAKE_PLAN_COVERAGE_CONTRACT_INVALID")
|
|
383
|
+
|
|
384
|
+
|
|
190
385
|
def validate_intake_evidence(
|
|
191
386
|
bundle: dict[str, Any],
|
|
192
387
|
*,
|
|
@@ -232,15 +427,44 @@ def validate_intake_evidence(
|
|
|
232
427
|
qtxt = ""
|
|
233
428
|
qtxt = str(qtxt)
|
|
234
429
|
|
|
235
|
-
if sat not in
|
|
430
|
+
if sat not in ALLOWED_SATISFIED_BY:
|
|
236
431
|
res.ok = False
|
|
237
432
|
res.diagnostics.append(
|
|
238
433
|
f"Remediation: topic {k!r} must set satisfied_by to "
|
|
239
|
-
f"'answer_ref' or '
|
|
434
|
+
f"'answer_ref', 'assumption_confirmation_ref', or 'delegation_ref' (got {sat!r})."
|
|
240
435
|
)
|
|
241
436
|
missing_cov.append(k)
|
|
242
437
|
continue
|
|
243
438
|
|
|
439
|
+
if sat == "delegation_ref":
|
|
440
|
+
scope = str(row.get("delegation_scope") or "").strip()
|
|
441
|
+
rationale = str(row.get("delegation_rationale") or "").strip()
|
|
442
|
+
confidence = str(row.get("delegation_confidence") or "").strip().lower()
|
|
443
|
+
if not scope or not rationale or not confidence:
|
|
444
|
+
res.ok = False
|
|
445
|
+
res.add_code("INTAKE_DELEGATION_EVIDENCE_MISSING")
|
|
446
|
+
missing_fields = []
|
|
447
|
+
if not scope:
|
|
448
|
+
missing_fields.append("delegation_scope")
|
|
449
|
+
if not rationale:
|
|
450
|
+
missing_fields.append("delegation_rationale")
|
|
451
|
+
if not confidence:
|
|
452
|
+
missing_fields.append("delegation_confidence")
|
|
453
|
+
res.diagnostics.append(
|
|
454
|
+
f"Remediation: delegated topic {k!r} requires non-empty fields: "
|
|
455
|
+
+ ", ".join(missing_fields)
|
|
456
|
+
+ "."
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
if confidence not in DELEGATION_CONFIDENCE_VALUES:
|
|
460
|
+
res.ok = False
|
|
461
|
+
res.add_code("INTAKE_DELEGATION_EVIDENCE_INVALID")
|
|
462
|
+
res.diagnostics.append(
|
|
463
|
+
f"Remediation: delegated topic {k!r} delegation_confidence must be one of "
|
|
464
|
+
f"{sorted(DELEGATION_CONFIDENCE_VALUES)!r} (got {confidence!r})."
|
|
465
|
+
)
|
|
466
|
+
continue
|
|
467
|
+
|
|
244
468
|
irid = _row_run_id(bundle, row)
|
|
245
469
|
tit = _row_turn(row)
|
|
246
470
|
if irid is None or tit is None:
|
|
@@ -261,11 +485,18 @@ def validate_intake_evidence(
|
|
|
261
485
|
quoted_user_text=qtxt,
|
|
262
486
|
):
|
|
263
487
|
res.ok = False
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
488
|
+
if sat == "delegation_ref":
|
|
489
|
+
res.add_code("INTAKE_DELEGATION_EVIDENCE_INVALID")
|
|
490
|
+
res.diagnostics.append(
|
|
491
|
+
f"Remediation: delegated topic {k!r} ref is malformed or hash mismatch — rebuild ie: ref "
|
|
492
|
+
f"with DEC-0060 canonical JSON (sorted keys) and quoted_user_text."
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
res.diagnostics.append(
|
|
496
|
+
f"Remediation: topic {k!r} ref is malformed or hash mismatch — rebuild ie: ref "
|
|
497
|
+
f"with DEC-0060 canonical JSON (sorted keys) and quoted_user_text."
|
|
498
|
+
)
|
|
499
|
+
missing_cov.append(k)
|
|
269
500
|
|
|
270
501
|
if missing_cov:
|
|
271
502
|
res.ok = False
|
|
@@ -283,16 +514,23 @@ def validate_intake_evidence(
|
|
|
283
514
|
if k not in by_key:
|
|
284
515
|
continue
|
|
285
516
|
if k not in asked:
|
|
517
|
+
row = by_key[k]
|
|
518
|
+
if _row_uses_equivalent_evidence(row):
|
|
519
|
+
continue
|
|
286
520
|
res.ok = False
|
|
287
521
|
res.add_code("INTAKE_REQUIRED_TOPIC_MISSING")
|
|
288
522
|
res.diagnostics.append(
|
|
289
|
-
f"Remediation: add {k!r} to asked_topics
|
|
290
|
-
f"
|
|
523
|
+
f"Remediation: add {k!r} to asked_topics or mark evidence_source='equivalent_evidence_ref' "
|
|
524
|
+
f"with equivalent_evidence_ref when reusing previously captured equivalent evidence."
|
|
291
525
|
)
|
|
292
526
|
if k not in res.missing_topics:
|
|
293
527
|
res.missing_topics.append(k)
|
|
294
528
|
res.missing_topics = sorted(set(res.missing_topics))
|
|
295
529
|
|
|
530
|
+
# US-0081 / DEC-0064: first/new/broad intake requires complete-plan coverage contract.
|
|
531
|
+
if pack == "first-intake-pack":
|
|
532
|
+
_validate_plan_coverage_contract(bundle, res)
|
|
533
|
+
|
|
296
534
|
ac = bundle.get("assumptions_confirmed")
|
|
297
535
|
ac_str = ac if isinstance(ac, str) else ("(none)" if ac is None else str(ac))
|
|
298
536
|
|
|
@@ -397,3 +635,69 @@ def self_test() -> None:
|
|
|
397
635
|
r1 = validate_intake_evidence(bundle, intake_guided_mode=1)
|
|
398
636
|
assert r0.ok and r1.ok
|
|
399
637
|
assert r0.primary_codes == r1.primary_codes
|
|
638
|
+
|
|
639
|
+
# US-0083 delegated-topic pass path
|
|
640
|
+
delegated_key = "done_definition"
|
|
641
|
+
rows2 = []
|
|
642
|
+
for i, key in enumerate(small):
|
|
643
|
+
sat = "delegation_ref" if key == delegated_key else "answer_ref"
|
|
644
|
+
txt = f"d{i}"
|
|
645
|
+
row = {
|
|
646
|
+
"topic_key": key,
|
|
647
|
+
"satisfied_by": sat,
|
|
648
|
+
"quoted_user_text": txt,
|
|
649
|
+
"intake_run_id": rid,
|
|
650
|
+
"turn_index": 200 + i,
|
|
651
|
+
"ref": build_ie_ref(rid, 200 + i, key, sat, txt),
|
|
652
|
+
}
|
|
653
|
+
if sat == "delegation_ref":
|
|
654
|
+
row["delegation_scope"] = "Implementation defaults for done criteria wording"
|
|
655
|
+
row["delegation_rationale"] = "User asked to proceed without additional specificity."
|
|
656
|
+
row["delegation_confidence"] = "medium"
|
|
657
|
+
rows2.append(row)
|
|
658
|
+
delegated_bundle = {
|
|
659
|
+
"selected_pack": "small-intake-pack",
|
|
660
|
+
"intake_run_id": rid,
|
|
661
|
+
"asked_topics": list(small),
|
|
662
|
+
"missing_topics": [],
|
|
663
|
+
"assumptions_confirmed": "(none)",
|
|
664
|
+
"topic_coverage": rows2,
|
|
665
|
+
}
|
|
666
|
+
d0 = validate_intake_evidence(delegated_bundle, intake_guided_mode=0)
|
|
667
|
+
d1 = validate_intake_evidence(delegated_bundle, intake_guided_mode=1)
|
|
668
|
+
assert d0.ok and d1.ok
|
|
669
|
+
assert d0.primary_codes == d1.primary_codes
|
|
670
|
+
|
|
671
|
+
# First-intake full-plan coverage contract (US-0081 / DEC-0064)
|
|
672
|
+
first = PACK_REQUIRED_KEYS["first-intake-pack"]
|
|
673
|
+
first_rows = []
|
|
674
|
+
for i, key in enumerate(first):
|
|
675
|
+
first_rows.append(
|
|
676
|
+
{
|
|
677
|
+
"topic_key": key,
|
|
678
|
+
"satisfied_by": "answer_ref",
|
|
679
|
+
"quoted_user_text": f"b{i}",
|
|
680
|
+
"intake_run_id": rid,
|
|
681
|
+
"turn_index": i,
|
|
682
|
+
"ref": build_ie_ref(rid, i, key, "answer_ref", f"b{i}"),
|
|
683
|
+
}
|
|
684
|
+
)
|
|
685
|
+
full_bundle = {
|
|
686
|
+
"selected_pack": "first-intake-pack",
|
|
687
|
+
"intake_run_id": rid,
|
|
688
|
+
"asked_topics": list(first),
|
|
689
|
+
"missing_topics": [],
|
|
690
|
+
"assumptions_confirmed": "(none)",
|
|
691
|
+
"topic_coverage": first_rows,
|
|
692
|
+
"candidate_story_ids": ["US-9001", "US-9002"],
|
|
693
|
+
"plan_area_inventory": [
|
|
694
|
+
{"plan_area_id": "auth", "title": "Auth"},
|
|
695
|
+
{"plan_area_id": "billing", "title": "Billing"},
|
|
696
|
+
],
|
|
697
|
+
"plan_area_coverage": [
|
|
698
|
+
{"plan_area_id": "auth", "story_ids": ["US-9001"]},
|
|
699
|
+
{"plan_area_id": "billing", "story_ids": ["US-9002"]},
|
|
700
|
+
],
|
|
701
|
+
"coverage_complete": True,
|
|
702
|
+
}
|
|
703
|
+
assert validate_intake_evidence(full_bundle, intake_guided_mode=0).ok
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Validate intake_evidence JSON bundles (US-0078 / DEC-0060).
|
|
3
|
+
Validate intake_evidence JSON bundles (US-0078 / US-0083 / DEC-0060 / DEC-0067).
|
|
4
4
|
|
|
5
5
|
Used by PO workflow preflight and CI fixtures.
|
|
6
6
|
"""
|
|
@@ -21,7 +21,7 @@ import intake_evidence_lib # noqa: E402
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def main() -> int:
|
|
24
|
-
p = argparse.ArgumentParser(description="Validate intake_evidence JSON (US-0078).")
|
|
24
|
+
p = argparse.ArgumentParser(description="Validate intake_evidence JSON (US-0078/US-0083).")
|
|
25
25
|
p.add_argument("--file", help="Path to JSON file containing one intake_evidence object.")
|
|
26
26
|
p.add_argument(
|
|
27
27
|
"--stdin",
|