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 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
- return install_paths, clean_paths
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(source_root, script_dir)
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-37",
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 (US-0078 / DEC-0060 / R-0055).
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 ("answer_ref", "assumption_confirmation_ref"):
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 'assumption_confirmation_ref' (got {sat!r})."
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
- 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)
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 covered topics must have been "
290
- f"prompted in-session (R-0055 asked-vs-covered; DEC-0060)."
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",