its-magic 0.1.3-0 → 0.1.3-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/installer.ps1 +8 -0
  2. package/installer.py +8 -0
  3. package/installer.sh +1 -0
  4. package/package.json +1 -1
  5. package/scripts/check_intake_template_parity.py +62 -0
  6. package/template/.cursor/agents/curator.mdc +1 -0
  7. package/template/.cursor/agents/po.mdc +1 -0
  8. package/template/.cursor/agents/release.mdc +1 -0
  9. package/template/.cursor/commands/auto.md +90 -0
  10. package/template/.cursor/commands/execute.md +26 -0
  11. package/template/.cursor/commands/refresh-context.md +32 -0
  12. package/template/.cursor/commands/sovereign-critic.md +104 -0
  13. package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
  14. package/template/.cursor/model-catalog.local.example.json +9 -0
  15. package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
  16. package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
  17. package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
  18. package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
  19. package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
  20. package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
  21. package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
  22. package/template/.cursor/scratchpad.local.example.md +55 -0
  23. package/template/.cursor/scratchpad.md +203 -0
  24. package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
  25. package/template/decisions/DEC-0104.md +279 -0
  26. package/template/decisions/DEC-0105.md +231 -0
  27. package/template/decisions/DEC-0107.md +246 -0
  28. package/template/docs/engineering/architecture.md +78 -0
  29. package/template/docs/engineering/auto-orchestration-reference.md +7 -0
  30. package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
  31. package/template/docs/engineering/reason_codes.md +411 -0
  32. package/template/docs/engineering/runbook.md +1119 -0
  33. package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
  34. package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
  35. package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
  36. package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
  37. package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
  38. package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
  39. package/template/scripts/check_intake_template_parity.py +181 -0
  40. package/template/scripts/decision_ledger_lib.py +732 -0
  41. package/template/scripts/ledger_validate.py +153 -0
  42. package/template/scripts/model_tier_lib.py +680 -0
  43. package/template/scripts/model_tier_validate.py +368 -0
  44. package/template/scripts/parallel_dev_arbiter.py +923 -0
  45. package/template/scripts/release_trigger_adapters.py +843 -0
  46. package/template/scripts/self_healing_deploy_lib.py +463 -0
  47. package/template/scripts/self_healing_deploy_validate.py +78 -0
  48. package/template/scripts/sovereign_convergence_lib.py +994 -0
  49. package/template/scripts/sovereign_convergence_validate.py +206 -0
  50. package/template/scripts/sovereign_critic_lib.py +629 -0
  51. package/template/scripts/sovereign_critic_validate.py +131 -0
  52. package/template/scripts/sovereign_loop_lib.py +828 -0
  53. package/template/scripts/sovereign_loop_validate.py +122 -0
  54. package/template/scripts/sovereign_memory_lib.py +869 -0
  55. package/template/scripts/sovereign_memory_validate.py +153 -0
  56. package/template/scripts/sovereign_role_manifest_lib.py +547 -0
  57. package/template/scripts/sovereign_role_manifest_validate.py +105 -0
  58. package/template/tests/us0108_contract_test.py +207 -0
  59. package/template/tests/us0109_contract_test.py +209 -0
  60. package/template/tests/us0109_us0110_compose_test.py +34 -0
  61. package/template/tests/us0111_contract_test.py +345 -0
@@ -0,0 +1,732 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AI Decision Ledger helper library (US-0103 / DEC-0103).
4
+
5
+ Provides:
6
+ - Append-only JSONL ledger operations (append / read / schema_check / summary_digest)
7
+ - Plan-fidelity deviation classifier per AUTO_PLAN_FIDELITY mode
8
+ - QA cross-check ledger_findings block builder
9
+ - Fail-closed reason codes for ledger + plan-fidelity families
10
+
11
+ Reason codes (DEC-0103 §8):
12
+ PLAN_FIDELITY_VIOLATION, PLAN_FIDELITY_OVERRIDE, PLAN_FIDELITY_SCOPE_GATE,
13
+ PLAN_FIDELITY_EXTENSION, PLAN_FIDELITY_REORDER,
14
+ LEDGER_FILE_MISSING, LEDGER_SCHEMA_INVALID, LEDGER_APPEND_FAILED,
15
+ LEDGER_CORRUPT, LEDGER_READ_BOUND, LEDGER_DISABLED
16
+
17
+ Default-off: AI_DECISION_LEDGER=0 → zero overhead (no reads, no writes).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ import uuid
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from enum import Enum
29
+ from pathlib import Path
30
+ from typing import Dict, List, Optional, Tuple
31
+
32
+
33
+ # --- Scratchpad key contracts (DEC-0103 §1) -----------------------------------
34
+
35
+ AI_DECISION_LEDGER_KEY = "AI_DECISION_LEDGER"
36
+ AUTO_PLAN_FIDELITY_KEY = "AUTO_PLAN_FIDELITY"
37
+
38
+ AI_DECISION_LEDGER_VALUES = frozenset({"0", "1"})
39
+ AI_DECISION_LEDGER_DEFAULT = "0"
40
+
41
+ AUTO_PLAN_FIDELITY_VALUES = frozenset({"strict", "relaxed", "extended"})
42
+ AUTO_PLAN_FIDELITY_DEFAULT = "strict"
43
+
44
+
45
+ # --- Enums --------------------------------------------------------------------
46
+
47
+
48
+ class PlanFidelity(str, Enum):
49
+ STRICT = "strict"
50
+ RELAXED = "relaxed"
51
+ EXTENDED = "extended"
52
+
53
+
54
+ class RiskTier(str, Enum):
55
+ LOW = "low"
56
+ MEDIUM = "medium"
57
+ HIGH = "high"
58
+ CRITICAL = "critical"
59
+
60
+
61
+ class DecisionType(str, Enum):
62
+ PLAN_FIDELITY_VIOLATION = "PLAN_FIDELITY_VIOLATION"
63
+ PLAN_FIDELITY_OVERRIDE = "PLAN_FIDELITY_OVERRIDE"
64
+ PLAN_FIDELITY_SCOPE_GATE = "PLAN_FIDELITY_SCOPE_GATE"
65
+ PLAN_FIDELITY_EXTENSION = "PLAN_FIDELITY_EXTENSION"
66
+ PLAN_FIDELITY_REORDER = "PLAN_FIDELITY_REORDER"
67
+ LEDGER_DECISION = "LEDGER_DECISION"
68
+ LEDGER_DERIVATION = "LEDGER_DERIVATION"
69
+ LEDGER_PHASE_TRANSITION = "LEDGER_PHASE_TRANSITION"
70
+ LEDGER_DELEGATION = "LEDGER_DELEGATION"
71
+
72
+
73
+ class ReasonCode(str, Enum):
74
+ PLAN_FIDELITY_VIOLATION = "PLAN_FIDELITY_VIOLATION"
75
+ PLAN_FIDELITY_OVERRIDE = "PLAN_FIDELITY_OVERRIDE"
76
+ PLAN_FIDELITY_SCOPE_GATE = "PLAN_FIDELITY_SCOPE_GATE"
77
+ PLAN_FIDELITY_EXTENSION = "PLAN_FIDELITY_EXTENSION"
78
+ PLAN_FIDELITY_REORDER = "PLAN_FIDELITY_REORDER"
79
+ LEDGER_FILE_MISSING = "LEDGER_FILE_MISSING"
80
+ LEDGER_SCHEMA_INVALID = "LEDGER_SCHEMA_INVALID"
81
+ LEDGER_APPEND_FAILED = "LEDGER_APPEND_FAILED"
82
+ LEDGER_CORRUPT = "LEDGER_CORRUPT"
83
+ LEDGER_READ_BOUND = "LEDGER_READ_BOUND"
84
+ LEDGER_DISABLED = "LEDGER_DISABLED"
85
+
86
+
87
+ # --- Canonical phase ids (DEC-0086 / DEC-0087) --------------------------------
88
+
89
+ CANONICAL_PHASE_IDS = frozenset({
90
+ "ask", "refresh-context", "memory-audit", "status-reconcile", "pause",
91
+ "intake", "discovery", "research", "release", "plan-verify",
92
+ "architecture", "execute", "quick", "qa", "verify-work", "security-review",
93
+ "auto",
94
+ })
95
+
96
+ CANONICAL_ROLES = frozenset({
97
+ "po", "tech-lead", "dev", "qa", "security", "release", "curator",
98
+ })
99
+
100
+
101
+ # --- Deviation kind → decision_type mapping (DEC-0103 §3) --------------------
102
+
103
+ DEVIATION_KINDS = frozenset({
104
+ "drop_ac", "reorder_ac", "add_scope", "generic", "derivation",
105
+ "phase_transition", "delegation", "operator_override",
106
+ })
107
+
108
+
109
+ def _deviation_table(mode: PlanFidelity, deviation_kind: str) -> Tuple[DecisionType, ReasonCode, bool]:
110
+ """
111
+ §3 decision table. Returns (decision_type, reason_code, blocking).
112
+ """
113
+ if deviation_kind == "operator_override":
114
+ return (
115
+ DecisionType.PLAN_FIDELITY_OVERRIDE,
116
+ ReasonCode.PLAN_FIDELITY_OVERRIDE,
117
+ False,
118
+ )
119
+
120
+ if mode == PlanFidelity.STRICT:
121
+ if deviation_kind in ("drop_ac", "reorder_ac"):
122
+ return (
123
+ DecisionType.PLAN_FIDELITY_VIOLATION,
124
+ ReasonCode.PLAN_FIDELITY_VIOLATION,
125
+ True,
126
+ )
127
+ if deviation_kind == "add_scope":
128
+ return (
129
+ DecisionType.PLAN_FIDELITY_SCOPE_GATE,
130
+ ReasonCode.PLAN_FIDELITY_SCOPE_GATE,
131
+ True,
132
+ )
133
+
134
+ if mode == PlanFidelity.RELAXED:
135
+ if deviation_kind in ("drop_ac", "reorder_ac"):
136
+ return (
137
+ DecisionType.PLAN_FIDELITY_REORDER,
138
+ ReasonCode.PLAN_FIDELITY_REORDER,
139
+ False,
140
+ )
141
+ if deviation_kind == "add_scope":
142
+ return (
143
+ DecisionType.PLAN_FIDELITY_SCOPE_GATE,
144
+ ReasonCode.PLAN_FIDELITY_SCOPE_GATE,
145
+ True,
146
+ )
147
+
148
+ if mode == PlanFidelity.EXTENDED:
149
+ if deviation_kind == "add_scope":
150
+ return (
151
+ DecisionType.PLAN_FIDELITY_EXTENSION,
152
+ ReasonCode.PLAN_FIDELITY_EXTENSION,
153
+ False,
154
+ )
155
+ if deviation_kind in ("drop_ac", "reorder_ac"):
156
+ return (
157
+ DecisionType.PLAN_FIDELITY_REORDER,
158
+ ReasonCode.PLAN_FIDELITY_REORDER,
159
+ False,
160
+ )
161
+
162
+ return (DecisionType.LEDGER_DECISION, ReasonCode.PLAN_FIDELITY_REORDER, False)
163
+
164
+
165
+ # Deviation kind → DecisionType for non-fidelity generic kinds:
166
+
167
+ _GENERIC_DECISION_TYPE = {
168
+ "generic": DecisionType.LEDGER_DECISION,
169
+ "derivation": DecisionType.LEDGER_DERIVATION,
170
+ "phase_transition": DecisionType.LEDGER_PHASE_TRANSITION,
171
+ "delegation": DecisionType.LEDGER_DELEGATION,
172
+ }
173
+
174
+
175
+ # --- Schema v1 (DEC-0103 §2) --------------------------------------------------
176
+
177
+ LEDGER_SCHEMA_FIELDS = (
178
+ "ts", "orchestrator_run_id", "phase_id", "role",
179
+ "decision_id", "decision_type", "from_artifact", "to_artifact",
180
+ "rationale", "plan_fidelity", "cross_model_reviewed", "risk_tier",
181
+ )
182
+
183
+ LEDGER_REQUIRED_FIELDS = frozenset(LEDGER_SCHEMA_FIELDS)
184
+
185
+ ARTIFACT_NONE_SENTINEL = "(none)"
186
+
187
+
188
+ def _is_iso8601_utc(value: str) -> bool:
189
+ if not isinstance(value, str):
190
+ return False
191
+ if not value.endswith("Z"):
192
+ return False
193
+ try:
194
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
195
+ return True
196
+ except ValueError:
197
+ return False
198
+
199
+
200
+ def _is_uuidv4(value: str) -> bool:
201
+ if not isinstance(value, str):
202
+ return False
203
+ try:
204
+ u = uuid.UUID(value, version=4)
205
+ return str(u) == value.lower()
206
+ except ValueError:
207
+ return False
208
+
209
+
210
+ def schema_check(entry: dict) -> Tuple[bool, Optional[str]]:
211
+ """Validate one JSONL entry against DEC-0103 schema v1 (12-field)."""
212
+ if not isinstance(entry, dict):
213
+ return False, "Entry must be a JSON object"
214
+
215
+ missing = [k for k in LEDGER_REQUIRED_FIELDS if k not in entry]
216
+ if missing:
217
+ return False, f"Missing required fields: {', '.join(missing)}"
218
+
219
+ extra = [k for k in entry.keys() if k not in LEDGER_REQUIRED_FIELDS]
220
+ if extra:
221
+ return False, f"Unknown fields: {', '.join(extra)}"
222
+
223
+ if not _is_iso8601_utc(entry["ts"]):
224
+ return False, f"Field 'ts' is not an ISO 8601 UTC timestamp: {entry['ts']!r}"
225
+
226
+ if not isinstance(entry["orchestrator_run_id"], str) or not entry["orchestrator_run_id"].strip():
227
+ return False, "Field 'orchestrator_run_id' must be non-empty string"
228
+
229
+ if entry["phase_id"] not in CANONICAL_PHASE_IDS:
230
+ return False, f"Field 'phase_id' unknown: {entry['phase_id']!r}"
231
+
232
+ if entry["role"] not in CANONICAL_ROLES:
233
+ return False, f"Field 'role' unknown: {entry['role']!r}"
234
+
235
+ if not _is_uuidv4(entry["decision_id"]):
236
+ return False, f"Field 'decision_id' is not a valid UUIDv4: {entry['decision_id']!r}"
237
+
238
+ try:
239
+ DecisionType(entry["decision_type"])
240
+ except ValueError:
241
+ return False, f"Field 'decision_type' unknown: {entry['decision_type']!r}"
242
+
243
+ for path_field in ("from_artifact", "to_artifact"):
244
+ value = entry[path_field]
245
+ if not isinstance(value, str):
246
+ return False, f"Field '{path_field}' must be a string"
247
+
248
+ if not isinstance(entry["rationale"], str) or not entry["rationale"].strip():
249
+ return False, "Field 'rationale' must be a non-empty string"
250
+
251
+ try:
252
+ PlanFidelity(entry["plan_fidelity"])
253
+ except ValueError:
254
+ return False, f"Field 'plan_fidelity' unknown: {entry['plan_fidelity']!r}"
255
+
256
+ if not isinstance(entry["cross_model_reviewed"], bool):
257
+ return False, "Field 'cross_model_reviewed' must be a boolean"
258
+
259
+ try:
260
+ RiskTier(entry["risk_tier"])
261
+ except ValueError:
262
+ return False, f"Field 'risk_tier' unknown: {entry['risk_tier']!r}"
263
+
264
+ return True, None
265
+
266
+
267
+ # --- Path + enabled checks ----------------------------------------------------
268
+
269
+
270
+ def is_ledger_enabled(scratchpad: Optional[Dict[str, str]] = None) -> bool:
271
+ """AI_DECISION_LEDGER=1 check. Default off."""
272
+ if not scratchpad:
273
+ return False
274
+ value = (scratchpad.get(AI_DECISION_LEDGER_KEY) or AI_DECISION_LEDGER_DEFAULT).strip()
275
+ return value == "1"
276
+
277
+
278
+ def resolve_plan_fidelity(scratchpad: Optional[Dict[str, str]] = None) -> PlanFidelity:
279
+ """AUTO_PLAN_FIDELITY value. Default strict."""
280
+ if not scratchpad:
281
+ return PlanFidelity.STRICT
282
+ value = (scratchpad.get(AUTO_PLAN_FIDELITY_KEY) or AUTO_PLAN_FIDELITY_DEFAULT).strip()
283
+ if value not in AUTO_PLAN_FIDELITY_VALUES:
284
+ return PlanFidelity.STRICT
285
+ return PlanFidelity(value)
286
+
287
+
288
+ def resolve_ledger_path(
289
+ orchestrator_run_id: str,
290
+ repo_root: Optional[Path] = None,
291
+ ) -> Path:
292
+ """Canonical ledger path per orchestrator run (DEC-0103 §2)."""
293
+ if not orchestrator_run_id or not orchestrator_run_id.strip():
294
+ raise ValueError("orchestrator_run_id must be a non-empty string")
295
+ base = Path(repo_root) if repo_root else Path.cwd()
296
+ return base / "handoffs" / "sovereign_decisions" / f"{orchestrator_run_id.strip()}.jsonl"
297
+
298
+
299
+ # --- Core operations ----------------------------------------------------------
300
+
301
+
302
+ def _fsync_file(path: Path) -> None:
303
+ """fsync a file; best-effort on Windows where some handles reject it."""
304
+ try:
305
+ fd = os.open(str(path), os.O_RDONLY)
306
+ try:
307
+ os.fsync(fd)
308
+ except OSError:
309
+ pass
310
+ finally:
311
+ os.close(fd)
312
+ except OSError:
313
+ pass
314
+
315
+
316
+ @dataclass
317
+ class AppendResult:
318
+ success: bool
319
+ reason_code: Optional[ReasonCode] = None
320
+ reason_message: Optional[str] = None
321
+ decision_id: Optional[str] = None
322
+
323
+
324
+ def _ensure_parent_dir(path: Path) -> Tuple[bool, Optional[ReasonCode], Optional[str]]:
325
+ try:
326
+ path.parent.mkdir(parents=True, exist_ok=True)
327
+ return True, None, None
328
+ except OSError as exc:
329
+ return False, ReasonCode.LEDGER_APPEND_FAILED, f"Cannot create {path.parent}: {exc}"
330
+
331
+
332
+ def append_entry(
333
+ ledger_path: Path,
334
+ entry: dict,
335
+ *,
336
+ scratchpad: Optional[Dict[str, str]] = None,
337
+ ) -> AppendResult:
338
+ """
339
+ Append one JSONL entry with fsync semantics.
340
+
341
+ Default-off: when AI_DECISION_LEDGER=0 (default), returns success with LEDGER_DISABLED
342
+ reason code (informational) and does NOT touch the filesystem.
343
+ """
344
+ if not is_ledger_enabled(scratchpad):
345
+ return AppendResult(
346
+ success=True,
347
+ reason_code=ReasonCode.LEDGER_DISABLED,
348
+ reason_message="AI_DECISION_LEDGER=0 (default); zero overhead; no file written",
349
+ )
350
+
351
+ ok, err = schema_check(entry)
352
+ if not ok:
353
+ return AppendResult(
354
+ success=False,
355
+ reason_code=ReasonCode.LEDGER_SCHEMA_INVALID,
356
+ reason_message=err or "Schema validation failed",
357
+ decision_id=entry.get("decision_id"),
358
+ )
359
+
360
+ ok_dir, rc_dir, msg_dir = _ensure_parent_dir(ledger_path)
361
+ if not ok_dir:
362
+ return AppendResult(success=False, reason_code=rc_dir, reason_message=msg_dir)
363
+
364
+ try:
365
+ line = json.dumps(entry, separators=(",", ":"), sort_keys=False, ensure_ascii=False) + "\n"
366
+ with open(ledger_path, "a", encoding="utf-8") as fh:
367
+ fh.write(line)
368
+ fh.flush()
369
+ _fsync_file(ledger_path)
370
+ return AppendResult(success=True, decision_id=entry.get("decision_id"))
371
+ except OSError as exc:
372
+ return AppendResult(
373
+ success=False,
374
+ reason_code=ReasonCode.LEDGER_APPEND_FAILED,
375
+ reason_message=f"Append failed: {exc}",
376
+ decision_id=entry.get("decision_id"),
377
+ )
378
+
379
+
380
+ def read_entries(
381
+ ledger_path: Path,
382
+ *,
383
+ last_n: Optional[int] = None,
384
+ strict: bool = True,
385
+ ) -> Tuple[List[dict], Optional[ReasonCode], Optional[str]]:
386
+ """
387
+ Read JSONL ledger entries. Returns (entries, reason_code, message).
388
+
389
+ `last_n` bounds the tail returned (default None = all when ledger present).
390
+ When `strict=True`, any schema-invalid line hard-stops and emits LEDGER_SCHEMA_INVALID.
391
+ When `strict=False`, invalid lines are skipped and reason code reflects LEDGER_READ_BOUND.
392
+ """
393
+ if not ledger_path.exists():
394
+ return [], ReasonCode.LEDGER_FILE_MISSING, f"Ledger file not found: {ledger_path}"
395
+
396
+ try:
397
+ text = ledger_path.read_text(encoding="utf-8")
398
+ except UnicodeDecodeError as exc:
399
+ return [], ReasonCode.LEDGER_CORRUPT, f"UTF-8 decode failed: {exc}"
400
+ except OSError as exc:
401
+ return [], ReasonCode.LEDGER_CORRUPT, f"Read failed: {exc}"
402
+
403
+ raw_lines = [ln for ln in text.split("\n") if ln.strip()]
404
+ parsed: List[dict] = []
405
+ for idx, raw in enumerate(raw_lines, start=1):
406
+ try:
407
+ obj = json.loads(raw)
408
+ except json.JSONDecodeError as exc:
409
+ if strict:
410
+ return [], ReasonCode.LEDGER_CORRUPT, f"Line {idx}: JSON decode error: {exc}"
411
+ continue
412
+ ok, err = schema_check(obj)
413
+ if not ok:
414
+ if strict:
415
+ return [], ReasonCode.LEDGER_SCHEMA_INVALID, f"Line {idx}: {err}"
416
+ continue
417
+ parsed.append(obj)
418
+
419
+ if last_n is not None and last_n > 0:
420
+ parsed = parsed[-last_n:]
421
+
422
+ warn = None
423
+ if last_n is not None and len(raw_lines) > last_n:
424
+ warn = ReasonCode.LEDGER_READ_BOUND
425
+
426
+ return parsed, warn, f"Bounded to last {last_n} of {len(raw_lines)} lines" if warn else None
427
+
428
+
429
+ def classify_deviation(
430
+ mode: PlanFidelity,
431
+ deviation_kind: str,
432
+ ) -> Tuple[DecisionType, ReasonCode, bool]:
433
+ """
434
+ DEC-0103 §3 deviation classifier. Single source of truth.
435
+
436
+ Returns (decision_type, reason_code, blocking).
437
+ """
438
+ if deviation_kind not in DEVIATION_KINDS:
439
+ return (
440
+ DecisionType.LEDGER_DECISION,
441
+ ReasonCode.PLAN_FIDELITY_VIOLATION,
442
+ False,
443
+ )
444
+
445
+ if deviation_kind in _GENERIC_DECISION_TYPE:
446
+ dt = _GENERIC_DECISION_TYPE[deviation_kind]
447
+ return (dt, ReasonCode.PLAN_FIDELITY_OVERRIDE, False)
448
+
449
+ return _deviation_table(mode, deviation_kind)
450
+
451
+
452
+ # --- Summary digest (DEC-0103 §6) --------------------------------------------
453
+
454
+ _DECISION_TYPE_ZERO_MAP = {dt.value: 0 for dt in DecisionType}
455
+ _RISK_TIER_ZERO_MAP = {rt.value: 0 for rt in RiskTier}
456
+
457
+
458
+ def summary_digest(entries: List[dict]) -> dict:
459
+ """
460
+ Build bounded QA digest — DEC-0103 §6 shape.
461
+ """
462
+ digest = {
463
+ "total_decisions": 0,
464
+ "by_type": dict(_DECISION_TYPE_ZERO_MAP),
465
+ "by_risk_tier": dict(_RISK_TIER_ZERO_MAP),
466
+ "violation_count": 0,
467
+ "override_count": 0,
468
+ "scope_gate_count": 0,
469
+ "extension_count": 0,
470
+ }
471
+ for entry in entries:
472
+ digest["total_decisions"] += 1
473
+ dt = entry.get("decision_type")
474
+ rt = entry.get("risk_tier")
475
+ if dt in digest["by_type"]:
476
+ digest["by_type"][dt] += 1
477
+ if rt in digest["by_risk_tier"]:
478
+ digest["by_risk_tier"][rt] += 1
479
+ if dt == DecisionType.PLAN_FIDELITY_VIOLATION.value:
480
+ digest["violation_count"] += 1
481
+ elif dt == DecisionType.PLAN_FIDELITY_OVERRIDE.value:
482
+ digest["override_count"] += 1
483
+ elif dt == DecisionType.PLAN_FIDELITY_SCOPE_GATE.value:
484
+ digest["scope_gate_count"] += 1
485
+ elif dt == DecisionType.PLAN_FIDELITY_EXTENSION.value:
486
+ digest["extension_count"] += 1
487
+ return digest
488
+
489
+
490
+ def build_qa_findings_block(
491
+ ledger_path: Path,
492
+ orchestrator_run_id: str,
493
+ *,
494
+ scratchpad: Optional[Dict[str, str]] = None,
495
+ ) -> Tuple[dict, Optional[ReasonCode]]:
496
+ """
497
+ Build `ledger_findings` block for QA emission (DEC-0103 §6).
498
+ Returns (block_dict, blocking_reason_code_or_None).
499
+ """
500
+ if not is_ledger_enabled(scratchpad):
501
+ return {
502
+ "ledger_findings": [],
503
+ "ledger_summary_digest": summary_digest([]),
504
+ "ledger_source": str(ledger_path),
505
+ "ledger_orchestrator_run_id": orchestrator_run_id,
506
+ "ledger_status": "disabled",
507
+ }, ReasonCode.LEDGER_DISABLED
508
+
509
+ entries, reason, message = read_entries(ledger_path, last_n=100)
510
+ if reason == ReasonCode.LEDGER_FILE_MISSING:
511
+ return {
512
+ "ledger_findings": [],
513
+ "ledger_summary_digest": summary_digest([]),
514
+ "ledger_source": str(ledger_path),
515
+ "ledger_orchestrator_run_id": orchestrator_run_id,
516
+ "ledger_status": "file_missing",
517
+ "ledger_error": message or "Ledger file does not exist",
518
+ }, ReasonCode.LEDGER_FILE_MISSING
519
+
520
+ if reason in (ReasonCode.LEDGER_SCHEMA_INVALID, ReasonCode.LEDGER_CORRUPT):
521
+ return {
522
+ "ledger_findings": [],
523
+ "ledger_summary_digest": summary_digest([]),
524
+ "ledger_source": str(ledger_path),
525
+ "ledger_orchestrator_run_id": orchestrator_run_id,
526
+ "ledger_status": "schema_invalid",
527
+ "ledger_error": message or "Ledger lines failed schema_check",
528
+ }, reason
529
+
530
+ findings: List[dict] = []
531
+ for entry in entries:
532
+ rationale = (entry.get("rationale") or "").strip()
533
+ findings.append({
534
+ "decision_id": entry["decision_id"],
535
+ "decision_type": entry["decision_type"],
536
+ "phase_id": entry["phase_id"],
537
+ "rationale_summary": rationale[:200],
538
+ "risk_tier": entry["risk_tier"],
539
+ "plan_fidelity_mode": entry["plan_fidelity"],
540
+ "cross_model_reviewed": bool(entry.get("cross_model_reviewed", False)),
541
+ })
542
+
543
+ return {
544
+ "ledger_findings": findings,
545
+ "ledger_summary_digest": summary_digest(entries),
546
+ "ledger_source": str(ledger_path),
547
+ "ledger_orchestrator_run_id": orchestrator_run_id,
548
+ "ledger_status": "ok",
549
+ }, None
550
+
551
+
552
+ # --- Builder convenience ------------------------------------------------------
553
+
554
+
555
+ def build_new_entry(
556
+ *,
557
+ orchestrator_run_id: str,
558
+ phase_id: str,
559
+ role: str,
560
+ decision_type: str,
561
+ rationale: str,
562
+ plan_fidelity: str,
563
+ risk_tier: str = "medium",
564
+ from_artifact: str = ARTIFACT_NONE_SENTINEL,
565
+ to_artifact: str = ARTIFACT_NONE_SENTINEL,
566
+ cross_model_reviewed: bool = False,
567
+ decision_id: Optional[str] = None,
568
+ ts: Optional[str] = None,
569
+ ) -> dict:
570
+ """Build a valid ledger entry dict. Raises ValueError on invalid inputs."""
571
+ ts_value = ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
572
+ did = decision_id or str(uuid.uuid4())
573
+ return {
574
+ "ts": ts_value,
575
+ "orchestrator_run_id": orchestrator_run_id,
576
+ "phase_id": phase_id,
577
+ "role": role,
578
+ "decision_id": did,
579
+ "decision_type": decision_type,
580
+ "from_artifact": from_artifact,
581
+ "to_artifact": to_artifact,
582
+ "rationale": rationale,
583
+ "plan_fidelity": plan_fidelity,
584
+ "cross_model_reviewed": bool(cross_model_reviewed),
585
+ "risk_tier": risk_tier,
586
+ }
587
+
588
+
589
+ # --- Self-test ----------------------------------------------------------------
590
+
591
+
592
+ def self_test() -> bool:
593
+ """Run self-test to validate library contract (US-0103 / DEC-0103)."""
594
+ print("[SELF-TEST] Validating decision_ledger_lib contract...")
595
+ errors: List[str] = []
596
+
597
+ # Scratchpad literals
598
+ if AI_DECISION_LEDGER_VALUES != {"0", "1"}:
599
+ errors.append(f"AI_DECISION_LEDGER_VALUES mismatch: {AI_DECISION_LEDGER_VALUES}")
600
+ if AI_DECISION_LEDGER_DEFAULT != "0":
601
+ errors.append(f"AI_DECISION_LEDGER_DEFAULT mismatch: {AI_DECISION_LEDGER_DEFAULT}")
602
+ if AUTO_PLAN_FIDELITY_VALUES != {"strict", "relaxed", "extended"}:
603
+ errors.append(f"AUTO_PLAN_FIDELITY_VALUES mismatch: {AUTO_PLAN_FIDELITY_VALUES}")
604
+ if AUTO_PLAN_FIDELITY_DEFAULT != "strict":
605
+ errors.append(f"AUTO_PLAN_FIDELITY_DEFAULT mismatch: {AUTO_PLAN_FIDELITY_DEFAULT}")
606
+
607
+ # Enum cardinalities (DEC-0103 §8 — 5 + 6 = 11 reason codes; 3+4+9 = 16 decisions)
608
+ if len(ReasonCode) != 11:
609
+ errors.append(f"ReasonCode count: expected 11, got {len(ReasonCode)}")
610
+ plan_fidelity_codes = {c for c in ReasonCode if c.value.startswith("PLAN_FIDELITY_")}
611
+ ledger_codes = {c for c in ReasonCode if c.value.startswith("LEDGER_")}
612
+ if len(plan_fidelity_codes) != 5:
613
+ errors.append(f"PLAN_FIDELITY_* codes expected 5, got {len(plan_fidelity_codes)}")
614
+ if len(ledger_codes) != 6:
615
+ errors.append(f"LEDGER_* codes expected 6, got {len(ledger_codes)}")
616
+
617
+ # is_ledger_enabled / is_ledger_enabled default
618
+ if is_ledger_enabled(None) or is_ledger_enabled({}) or is_ledger_enabled({"AI_DECISION_LEDGER": "0"}):
619
+ errors.append("is_ledger_enabled should be False by default")
620
+ if not is_ledger_enabled({"AI_DECISION_LEDGER": "1"}):
621
+ errors.append("is_ledger_enabled should be True when key=1")
622
+
623
+ # resolve_plan_fidelity default path
624
+ if resolve_plan_fidelity(None) != PlanFidelity.STRICT:
625
+ errors.append("resolve_plan_fidelity default should be strict")
626
+
627
+ # schema_check + append round-trip
628
+ good = build_new_entry(
629
+ orchestrator_run_id="self-test-run-1",
630
+ phase_id="research",
631
+ role="tech-lead",
632
+ decision_type=DecisionType.LEDGER_DECISION.value,
633
+ rationale="Self-test rationale.",
634
+ plan_fidelity=PlanFidelity.STRICT.value,
635
+ risk_tier=RiskTier.LOW.value,
636
+ )
637
+ ok, err = schema_check(good)
638
+ if not ok:
639
+ errors.append(f"schema_check should accept valid entry: {err}")
640
+
641
+ bad = dict(good)
642
+ bad["decision_id"] = "not-a-uuid"
643
+ ok_b, err_b = schema_check(bad)
644
+ if ok_b:
645
+ errors.append("schema_check should reject invalid decision_id")
646
+ if err_b is None:
647
+ errors.append("schema_check error message must be non-None on failure")
648
+
649
+ bad2 = dict(good)
650
+ bad2["phase_id"] = "not-a-phase"
651
+ ok_b2, err_b2 = schema_check(bad2)
652
+ if ok_b2:
653
+ errors.append("schema_check should reject unknown phase_id")
654
+
655
+ # classify_deviation strict + drop_ac → blocking
656
+ dt, rc, blocking = classify_deviation(PlanFidelity.STRICT, "drop_ac")
657
+ if not blocking or dt != DecisionType.PLAN_FIDELITY_VIOLATION:
658
+ errors.append(f"strict+drop_ac should be blocking PLAN_FIDELITY_VIOLATION (got {dt}, {blocking})")
659
+
660
+ # relaxed+reorder_ac → non-blocking
661
+ dt, rc, blocking = classify_deviation(PlanFidelity.RELAXED, "reorder_ac")
662
+ if blocking or dt != DecisionType.PLAN_FIDELITY_REORDER:
663
+ errors.append(f"relaxed+reorder_ac should be non-blocking PLAN_FIDELITY_REORDER (got {dt}, {blocking})")
664
+
665
+ # extended+add_scope → non-blocking extension
666
+ dt, rc, blocking = classify_deviation(PlanFidelity.EXTENDED, "add_scope")
667
+ if blocking or dt != DecisionType.PLAN_FIDELITY_EXTENSION:
668
+ errors.append(f"extended+add_scope should be non-blocking PLAN_FIDELITY_EXTENSION (got {dt}, {blocking})")
669
+
670
+ # summary_digest shape
671
+ digest = summary_digest([good])
672
+ if digest["total_decisions"] != 1:
673
+ errors.append("summary_digest total_decisions != 1")
674
+ if "violation_count" not in digest or "by_type" not in digest or "by_risk_tier" not in digest:
675
+ errors.append("summary_digest shape missing required keys")
676
+
677
+ if errors:
678
+ print("[SELF_TEST_FAILED]")
679
+ for err in errors:
680
+ print(f" {err}", file=sys.stderr)
681
+ return False
682
+
683
+ print("[DECISION_LEDGER_SELF_TEST_OK]")
684
+ return True
685
+
686
+
687
+ # --- CLI ----------------------------------------------------------------------
688
+
689
+
690
+ if __name__ == "__main__":
691
+ import argparse
692
+
693
+ parser = argparse.ArgumentParser(description="Decision ledger library (US-0103 / DEC-0103)")
694
+ parser.add_argument("--self-test", action="store_true", help="Run self-test")
695
+ parser.add_argument("--append-json", help="JSON string of entry to append")
696
+ parser.add_argument("--ledger", type=Path, help="Ledger path for --append-json")
697
+ parser.add_argument("--orchestrator-run-id", help="Resolve path for --append-json")
698
+ parser.add_argument("--dump-digest", type=Path, help="Print summary_digest for ledger path")
699
+ parser.add_argument("--repo", type=Path, default=None, help="Repo root for ledger path resolution")
700
+
701
+ args = parser.parse_args()
702
+
703
+ if args.self_test:
704
+ sys.exit(0 if self_test() else 1)
705
+
706
+ if args.dump_digest:
707
+ entries, reason, message = read_entries(args.dump_digest, strict=True)
708
+ if reason in (ReasonCode.LEDGER_FILE_MISSING, ReasonCode.LEDGER_SCHEMA_INVALID, ReasonCode.LEDGER_CORRUPT):
709
+ print(f"[LEDGER_READ_FAIL] {reason.value}: {message}", file=sys.stderr)
710
+ sys.exit(1)
711
+ print(json.dumps(summary_digest(entries), indent=2, sort_keys=True))
712
+ sys.exit(0)
713
+
714
+ if args.append_json:
715
+ if not args.ledger and not args.orchestrator_run_id:
716
+ print("[USAGE] --ledger or --orchestrator-run-id required", file=sys.stderr)
717
+ sys.exit(2)
718
+ path = args.ledger or resolve_ledger_path(args.orchestrator_run_id, args.repo)
719
+ try:
720
+ entry = json.loads(args.append_json)
721
+ except json.JSONDecodeError as exc:
722
+ print(f"[USAGE] invalid --append-json: {exc}", file=sys.stderr)
723
+ sys.exit(2)
724
+ res = append_entry(path, entry)
725
+ if res.success:
726
+ print(f"[OK] decision_id={res.decision_id} reason={res.reason_code.value if res.reason_code else 'appended'}")
727
+ sys.exit(0)
728
+ print(f"[FAIL] {res.reason_code.value if res.reason_code else 'append_failed'}: {res.reason_message}", file=sys.stderr)
729
+ sys.exit(1)
730
+
731
+ parser.print_help()
732
+ sys.exit(0)