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,869 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sovereign Memory helper library (US-0105 / DEC-0105).
4
+
5
+ Project-level institutional memory: bounded JSONL artifacts under
6
+ docs/engineering/sovereign-memory/ with phase-spawn injection digest.
7
+
8
+ Reason codes (DEC-0105 §9):
9
+ SOVEREIGN_MEMORY_DISABLED, SOVEREIGN_MEMORY_SCHEMA_INVALID,
10
+ SOVEREIGN_MEMORY_APPEND_FAILED, SOVEREIGN_MEMORY_DECISION_DUPLICATE,
11
+ SOVEREIGN_MEMORY_SECRET_DETECTED, SOVEREIGN_MEMORY_ARCHIVE_REQUIRED,
12
+ SOVEREIGN_MEMORY_READ_BOUND, SOVEREIGN_MEMORY_PROMOTION_SKIPPED
13
+
14
+ Default-off: SOVEREIGN_MEMORY=0 → zero overhead (no reads, no writes, no digest).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from enum import Enum
28
+ from pathlib import Path
29
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
30
+
31
+ SCHEMA_VERSION = 1
32
+ MEMORY_DIR_REL = "docs/engineering/sovereign-memory"
33
+ ARCHIVE_DIR_REL = "docs/engineering/sovereign-memory-archive"
34
+ RETROSPECTIVES_SUBDIR = "retrospectives"
35
+
36
+ SOVEREIGN_MEMORY_KEY = "SOVEREIGN_MEMORY"
37
+ SOVEREIGN_MEMORY_TOP_N_KEY = "SOVEREIGN_MEMORY_TOP_N"
38
+ SOVEREIGN_MEMORY_TOP_K_KEY = "SOVEREIGN_MEMORY_TOP_K"
39
+ SOVEREIGN_MEMORY_MAX_CHARS_KEY = "SOVEREIGN_MEMORY_MAX_CHARS"
40
+ SOVEREIGN_MEMORY_JSONL_MAX_LINES_KEY = "SOVEREIGN_MEMORY_JSONL_MAX_LINES"
41
+
42
+ SOVEREIGN_MEMORY_VALUES = frozenset({"0", "1"})
43
+ SOVEREIGN_MEMORY_DEFAULT = "0"
44
+ SOVEREIGN_MEMORY_TOP_N_DEFAULT = 5
45
+ SOVEREIGN_MEMORY_TOP_K_DEFAULT = 3
46
+ SOVEREIGN_MEMORY_MAX_CHARS_DEFAULT = 2048
47
+ SOVEREIGN_MEMORY_JSONL_MAX_LINES_DEFAULT = 500
48
+ SOVEREIGN_MEMORY_READ_TAIL_DEFAULT = 500
49
+
50
+ TEXT_MAX_CHARS = 2000
51
+ RATIONALE_MAX_CHARS = 2000
52
+ TAGS_MAX = 10
53
+ TAG_MAX_CHARS = 40
54
+ DECISION_KEY_HEX_LEN = 16
55
+
56
+ JSONL_FAMILIES = frozenset({"decisions", "mistakes", "patterns", "plan-drift"})
57
+ JSONL_FILENAMES = {
58
+ "decisions": "decisions-log.jsonl",
59
+ "mistakes": "mistakes.jsonl",
60
+ "patterns": "patterns.jsonl",
61
+ "plan-drift": "plan-drift-register.jsonl",
62
+ }
63
+
64
+ STATUS_VALUES = frozenset({"active", "superseded", "archived"})
65
+ MISTAKE_TAG_VALUES = frozenset({
66
+ "fix_failed",
67
+ "revert_applied",
68
+ "plan_fidelity_violation",
69
+ "test_regression",
70
+ "scope_creep",
71
+ })
72
+ MISTAKE_HOOK_TABLE = {
73
+ "fix_failed": "FIX_FAILED",
74
+ "revert_applied": "REVERT_APPLIED",
75
+ "plan_fidelity_violation": "PLAN_FIDELITY_VIOLATION",
76
+ "test_regression": "TEST_REGRESSION",
77
+ "scope_creep": "PLAN_FIDELITY_SCOPE_GATE",
78
+ }
79
+ DRIFT_TYPE_VALUES = frozenset({
80
+ "ac_drop",
81
+ "ac_reorder",
82
+ "scope_add",
83
+ "plan_change",
84
+ "acceptance_drift",
85
+ })
86
+
87
+ BASE_REQUIRED_FIELDS = frozenset({
88
+ "schema_version",
89
+ "ts",
90
+ "entry_id",
91
+ "impact_score",
92
+ "text",
93
+ "tags",
94
+ "status",
95
+ })
96
+
97
+ DECISIONS_REQUIRED = BASE_REQUIRED_FIELDS | frozenset({
98
+ "decision_key",
99
+ "decision_text",
100
+ "rationale",
101
+ })
102
+ MISTAKES_REQUIRED = BASE_REQUIRED_FIELDS | frozenset({
103
+ "mistake_tag",
104
+ "failure_reason_code",
105
+ })
106
+ PATTERNS_REQUIRED = BASE_REQUIRED_FIELDS | frozenset({
107
+ "pattern_id",
108
+ "applies_to",
109
+ })
110
+ PLAN_DRIFT_REQUIRED = BASE_REQUIRED_FIELDS | frozenset({
111
+ "drift_type",
112
+ "from_artifact",
113
+ "to_artifact",
114
+ })
115
+
116
+ FAMILY_REQUIRED = {
117
+ "decisions": DECISIONS_REQUIRED,
118
+ "mistakes": MISTAKES_REQUIRED,
119
+ "patterns": PATTERNS_REQUIRED,
120
+ "plan-drift": PLAN_DRIFT_REQUIRED,
121
+ }
122
+
123
+ SECRET_PATTERNS = (
124
+ re.compile(r"(?i)(api[_-]?key|secret|password|token|bearer)\s*[:=]\s*\S+"),
125
+ re.compile(r"(?i)-----BEGIN (RSA |EC )?PRIVATE KEY-----"),
126
+ re.compile(r"sk-[a-zA-Z0-9]{20,}"),
127
+ re.compile(r"ghp_[a-zA-Z0-9]{20,}"),
128
+ )
129
+
130
+
131
+ class ReasonCode(str, Enum):
132
+ SOVEREIGN_MEMORY_DISABLED = "SOVEREIGN_MEMORY_DISABLED"
133
+ SOVEREIGN_MEMORY_SCHEMA_INVALID = "SOVEREIGN_MEMORY_SCHEMA_INVALID"
134
+ SOVEREIGN_MEMORY_APPEND_FAILED = "SOVEREIGN_MEMORY_APPEND_FAILED"
135
+ SOVEREIGN_MEMORY_DECISION_DUPLICATE = "SOVEREIGN_MEMORY_DECISION_DUPLICATE"
136
+ SOVEREIGN_MEMORY_SECRET_DETECTED = "SOVEREIGN_MEMORY_SECRET_DETECTED"
137
+ SOVEREIGN_MEMORY_ARCHIVE_REQUIRED = "SOVEREIGN_MEMORY_ARCHIVE_REQUIRED"
138
+ SOVEREIGN_MEMORY_READ_BOUND = "SOVEREIGN_MEMORY_READ_BOUND"
139
+ SOVEREIGN_MEMORY_PROMOTION_SKIPPED = "SOVEREIGN_MEMORY_PROMOTION_SKIPPED"
140
+
141
+
142
+ @dataclass
143
+ class InjectionDigest:
144
+ digest_text: str
145
+ entry_ids: List[str] = field(default_factory=list)
146
+ char_count: int = 0
147
+
148
+
149
+ def _utc_now_iso() -> str:
150
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
151
+
152
+
153
+ def _archive_timestamp() -> str:
154
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
155
+
156
+
157
+ def _fsync_file(path: Path) -> None:
158
+ try:
159
+ fd = os.open(str(path), os.O_RDONLY)
160
+ try:
161
+ os.fsync(fd)
162
+ except OSError:
163
+ pass
164
+ finally:
165
+ os.close(fd)
166
+ except OSError:
167
+ pass
168
+
169
+
170
+ def parse_scratchpad_int(
171
+ scratchpad: Optional[Dict[str, str]],
172
+ key: str,
173
+ default: int,
174
+ *,
175
+ minimum: int = 0,
176
+ maximum: Optional[int] = None,
177
+ ) -> int:
178
+ pad = scratchpad or {}
179
+ raw = pad.get(key, str(default))
180
+ try:
181
+ value = int(str(raw).strip())
182
+ except (TypeError, ValueError):
183
+ value = default
184
+ if maximum is not None:
185
+ value = min(maximum, value)
186
+ return max(minimum, value)
187
+
188
+
189
+ def is_sovereign_memory_enabled(scratchpad: Optional[Dict[str, str]]) -> bool:
190
+ if not scratchpad:
191
+ return False
192
+ return scratchpad.get(SOVEREIGN_MEMORY_KEY, SOVEREIGN_MEMORY_DEFAULT).strip() == "1"
193
+
194
+
195
+ def resolve_memory_dir(repo_root: Optional[Path] = None) -> Path:
196
+ root = Path(repo_root or ".").resolve()
197
+ return root / MEMORY_DIR_REL
198
+
199
+
200
+ def resolve_archive_dir(repo_root: Optional[Path] = None) -> Path:
201
+ root = Path(repo_root or ".").resolve()
202
+ return root / ARCHIVE_DIR_REL
203
+
204
+
205
+ def resolve_jsonl_path(family: str, repo_root: Optional[Path] = None) -> Path:
206
+ if family not in JSONL_FAMILIES:
207
+ raise ValueError(f"unknown family: {family}")
208
+ return resolve_memory_dir(repo_root) / JSONL_FILENAMES[family]
209
+
210
+
211
+ def resolve_retrospectives_dir(repo_root: Optional[Path] = None) -> Path:
212
+ return resolve_memory_dir(repo_root) / RETROSPECTIVES_SUBDIR
213
+
214
+
215
+ def normalize_decision_text(text: str) -> str:
216
+ return " ".join(str(text).strip().lower().split())
217
+
218
+
219
+ def compute_decision_key(decision_text: str) -> str:
220
+ normalized = normalize_decision_text(decision_text)
221
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
222
+ return digest[:DECISION_KEY_HEX_LEN]
223
+
224
+
225
+ def scan_secrets(text: str) -> Optional[ReasonCode]:
226
+ for pattern in SECRET_PATTERNS:
227
+ if pattern.search(text or ""):
228
+ return ReasonCode.SOVEREIGN_MEMORY_SECRET_DETECTED
229
+ return None
230
+
231
+
232
+ def schema_check(entry: dict, family: str) -> Tuple[bool, Optional[str]]:
233
+ if family not in JSONL_FAMILIES:
234
+ return False, f"unknown family: {family}"
235
+ if not isinstance(entry, dict):
236
+ return False, "entry must be a dict"
237
+
238
+ required = FAMILY_REQUIRED[family]
239
+ missing = required - set(entry.keys())
240
+ if missing:
241
+ return False, f"missing fields: {sorted(missing)}"
242
+
243
+ if entry.get("schema_version") != SCHEMA_VERSION:
244
+ return False, "schema_version must be 1"
245
+
246
+ if entry.get("status") not in STATUS_VALUES:
247
+ return False, f"status must be one of {sorted(STATUS_VALUES)}"
248
+
249
+ impact = entry.get("impact_score")
250
+ if not isinstance(impact, int) or impact < 0 or impact > 100:
251
+ return False, "impact_score must be int 0..100"
252
+
253
+ tags = entry.get("tags")
254
+ if not isinstance(tags, list) or len(tags) > TAGS_MAX:
255
+ return False, f"tags must be list with at most {TAGS_MAX} items"
256
+ for tag in tags:
257
+ if not isinstance(tag, str) or len(tag) > TAG_MAX_CHARS:
258
+ return False, f"each tag must be string max {TAG_MAX_CHARS} chars"
259
+
260
+ text = entry.get("text", "")
261
+ if not isinstance(text, str) or not text.strip() or len(text) > TEXT_MAX_CHARS:
262
+ return False, f"text must be non-empty string max {TEXT_MAX_CHARS} chars"
263
+
264
+ secret = scan_secrets(text)
265
+ if secret:
266
+ return False, ReasonCode.SOVEREIGN_MEMORY_SECRET_DETECTED.value
267
+
268
+ if family == "decisions":
269
+ rationale = entry.get("rationale", "")
270
+ if not isinstance(rationale, str) or not rationale.strip() or len(rationale) > RATIONALE_MAX_CHARS:
271
+ return False, f"rationale must be non-empty max {RATIONALE_MAX_CHARS} chars"
272
+ if scan_secrets(rationale):
273
+ return False, ReasonCode.SOVEREIGN_MEMORY_SECRET_DETECTED.value
274
+ dt = entry.get("decision_text", "")
275
+ if scan_secrets(str(dt)):
276
+ return False, ReasonCode.SOVEREIGN_MEMORY_SECRET_DETECTED.value
277
+ expected_key = compute_decision_key(str(dt))
278
+ if entry.get("decision_key") != expected_key:
279
+ return False, "decision_key mismatch"
280
+
281
+ if family == "mistakes":
282
+ if entry.get("mistake_tag") not in MISTAKE_TAG_VALUES:
283
+ return False, f"mistake_tag must be one of {sorted(MISTAKE_TAG_VALUES)}"
284
+
285
+ if family == "patterns":
286
+ applies = entry.get("applies_to")
287
+ if not isinstance(applies, list):
288
+ return False, "applies_to must be a list"
289
+
290
+ if family == "plan-drift":
291
+ if entry.get("drift_type") not in DRIFT_TYPE_VALUES:
292
+ return False, f"drift_type must be one of {sorted(DRIFT_TYPE_VALUES)}"
293
+
294
+ return True, None
295
+
296
+
297
+ def _count_nonempty_lines(path: Path) -> int:
298
+ if not path.is_file():
299
+ return 0
300
+ count = 0
301
+ with path.open("r", encoding="utf-8") as handle:
302
+ for line in handle:
303
+ if line.strip():
304
+ count += 1
305
+ return count
306
+
307
+
308
+ def _load_existing_decision_keys(repo_root: Optional[Path] = None) -> Set[str]:
309
+ path = resolve_jsonl_path("decisions", repo_root)
310
+ if not path.is_file():
311
+ return set()
312
+ keys: Set[str] = set()
313
+ with path.open("r", encoding="utf-8") as handle:
314
+ for line in handle:
315
+ if not line.strip():
316
+ continue
317
+ try:
318
+ obj = json.loads(line)
319
+ except json.JSONDecodeError:
320
+ continue
321
+ key = obj.get("decision_key")
322
+ if isinstance(key, str):
323
+ keys.add(key)
324
+ return keys
325
+
326
+
327
+ def _ensure_memory_dir(repo_root: Optional[Path] = None) -> Tuple[bool, Optional[ReasonCode]]:
328
+ try:
329
+ resolve_memory_dir(repo_root).mkdir(parents=True, exist_ok=True)
330
+ return True, None
331
+ except OSError:
332
+ return False, ReasonCode.SOVEREIGN_MEMORY_APPEND_FAILED
333
+
334
+
335
+ def _append_jsonl_line(
336
+ family: str,
337
+ entry: dict,
338
+ repo_root: Optional[Path] = None,
339
+ scratchpad: Optional[Dict[str, str]] = None,
340
+ ) -> Tuple[bool, Optional[ReasonCode]]:
341
+ ok_dir, rc_dir = _ensure_memory_dir(repo_root)
342
+ if not ok_dir:
343
+ return False, rc_dir
344
+
345
+ ok_archive, rc_archive = maybe_archive_jsonl(family, repo_root, scratchpad)
346
+ if not ok_archive:
347
+ return False, rc_archive
348
+
349
+ path = resolve_jsonl_path(family, repo_root)
350
+ try:
351
+ line = json.dumps(entry, separators=(",", ":"), ensure_ascii=False) + "\n"
352
+ with path.open("a", encoding="utf-8") as handle:
353
+ handle.write(line)
354
+ handle.flush()
355
+ _fsync_file(path)
356
+ return True, None
357
+ except OSError:
358
+ return False, ReasonCode.SOVEREIGN_MEMORY_APPEND_FAILED
359
+
360
+
361
+ def read_entries(
362
+ family: str,
363
+ repo_root: Optional[Path] = None,
364
+ *,
365
+ tail_n: Optional[int] = None,
366
+ active_only: bool = True,
367
+ ) -> Tuple[List[dict], Optional[ReasonCode]]:
368
+ path = resolve_jsonl_path(family, repo_root)
369
+ if not path.exists():
370
+ return [], None
371
+
372
+ bound = tail_n if tail_n is not None else SOVEREIGN_MEMORY_READ_TAIL_DEFAULT
373
+ lines: List[str] = []
374
+ truncated = False
375
+ with path.open("r", encoding="utf-8") as handle:
376
+ for line in handle:
377
+ if line.strip():
378
+ lines.append(line)
379
+ if len(lines) > bound:
380
+ truncated = True
381
+ lines = lines[-bound:]
382
+
383
+ entries: List[dict] = []
384
+ for line in lines:
385
+ try:
386
+ obj = json.loads(line)
387
+ except json.JSONDecodeError:
388
+ return [], ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
389
+ ok, _ = schema_check(obj, family)
390
+ if not ok:
391
+ return [], ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
392
+ if active_only and obj.get("status") != "active":
393
+ continue
394
+ entries.append(obj)
395
+
396
+ if truncated:
397
+ return entries, ReasonCode.SOVEREIGN_MEMORY_READ_BOUND
398
+ return entries, None
399
+
400
+
401
+ def _sort_recent(entries: Sequence[dict]) -> List[dict]:
402
+ by_id = sorted(entries, key=lambda item: str(item.get("entry_id", "")))
403
+ by_ts = sorted(by_id, key=lambda item: str(item.get("ts", "")), reverse=True)
404
+ return by_ts
405
+
406
+
407
+ def _sort_high_impact(entries: Sequence[dict]) -> List[dict]:
408
+ by_id = sorted(entries, key=lambda item: str(item.get("entry_id", "")))
409
+ by_ts = sorted(by_id, key=lambda item: str(item.get("ts", "")), reverse=True)
410
+ return sorted(by_ts, key=lambda item: -int(item.get("impact_score", 0)))
411
+
412
+
413
+ def _format_digest_line(family: str, entry: dict) -> str:
414
+ label = JSONL_FILENAMES.get(family, family).replace(".jsonl", "")
415
+ text = str(entry.get("text", "")).strip()
416
+ if len(text) > 160:
417
+ text = text[:157] + "..."
418
+ return f"- [{label}] {entry.get('entry_id')}: {text}"
419
+
420
+
421
+ def build_injection_digest(
422
+ repo_root: Optional[Path] = None,
423
+ scratchpad: Optional[Dict[str, str]] = None,
424
+ ) -> InjectionDigest:
425
+ if not is_sovereign_memory_enabled(scratchpad):
426
+ return InjectionDigest(digest_text="", entry_ids=[], char_count=0)
427
+
428
+ top_n = parse_scratchpad_int(scratchpad, SOVEREIGN_MEMORY_TOP_N_KEY, SOVEREIGN_MEMORY_TOP_N_DEFAULT, minimum=0)
429
+ top_k = parse_scratchpad_int(scratchpad, SOVEREIGN_MEMORY_TOP_K_KEY, SOVEREIGN_MEMORY_TOP_K_DEFAULT, minimum=0)
430
+ max_chars = parse_scratchpad_int(
431
+ scratchpad,
432
+ SOVEREIGN_MEMORY_MAX_CHARS_KEY,
433
+ SOVEREIGN_MEMORY_MAX_CHARS_DEFAULT,
434
+ minimum=0,
435
+ )
436
+
437
+ all_recent: List[Tuple[str, dict]] = []
438
+ high_impact_pool: List[Tuple[str, dict]] = []
439
+
440
+ for family in JSONL_FAMILIES:
441
+ entries, _ = read_entries(family, repo_root, active_only=True)
442
+ for entry in entries:
443
+ all_recent.append((family, entry))
444
+ if family in ("patterns", "mistakes"):
445
+ high_impact_pool.append((family, entry))
446
+
447
+ if not all_recent:
448
+ placeholder = "(no sovereign memory entries)"
449
+ return InjectionDigest(
450
+ digest_text=placeholder,
451
+ entry_ids=[],
452
+ char_count=len(placeholder),
453
+ )
454
+
455
+ recent_sorted = _sort_recent([entry for _, entry in all_recent])
456
+ recent_ids: List[str] = []
457
+ recent_lines: List[str] = []
458
+ for entry in recent_sorted:
459
+ entry_id = str(entry.get("entry_id"))
460
+ if entry_id in recent_ids:
461
+ continue
462
+ recent_ids.append(entry_id)
463
+ family = next(f for f, e in all_recent if e.get("entry_id") == entry_id)
464
+ recent_lines.append(_format_digest_line(family, entry))
465
+ if len(recent_ids) >= top_n:
466
+ break
467
+
468
+ high_sorted = _sort_high_impact([entry for _, entry in high_impact_pool])
469
+ high_lines: List[str] = []
470
+ high_ids: List[str] = []
471
+ selected = set(recent_ids)
472
+ for entry in high_sorted:
473
+ entry_id = str(entry.get("entry_id"))
474
+ if entry_id in selected:
475
+ continue
476
+ selected.add(entry_id)
477
+ high_ids.append(entry_id)
478
+ family = next(f for f, e in high_impact_pool if e.get("entry_id") == entry_id)
479
+ high_lines.append(_format_digest_line(family, entry))
480
+ if len(high_ids) >= top_k:
481
+ break
482
+
483
+ sections: List[str] = []
484
+ if recent_lines:
485
+ sections.append("## Recent learnings\n" + "\n".join(recent_lines))
486
+ if high_lines:
487
+ sections.append("## High-impact patterns\n" + "\n".join(high_lines))
488
+
489
+ digest_text = "\n\n".join(sections) if sections else "(no sovereign memory entries)"
490
+ if max_chars > 0 and len(digest_text) > max_chars:
491
+ digest_text = digest_text[: max_chars - 3].rstrip() + "..."
492
+
493
+ ordered_ids = recent_ids + [item for item in high_ids if item not in recent_ids]
494
+ return InjectionDigest(
495
+ digest_text=digest_text,
496
+ entry_ids=ordered_ids,
497
+ char_count=len(digest_text),
498
+ )
499
+
500
+
501
+ def build_injection_digest_block(
502
+ repo_root: Optional[Path] = None,
503
+ scratchpad: Optional[Dict[str, str]] = None,
504
+ ) -> Optional[str]:
505
+ """Spawn assembler helper: fenced sovereign_memory_digest block or None when disabled."""
506
+ if not is_sovereign_memory_enabled(scratchpad):
507
+ return None
508
+ digest = build_injection_digest(repo_root, scratchpad)
509
+ return (
510
+ "### sovereign_memory_digest\n\n"
511
+ "```\n"
512
+ f"{digest.digest_text}\n"
513
+ "```\n"
514
+ )
515
+
516
+
517
+ def dedupe_decision(
518
+ decision_text: str,
519
+ existing_keys: Iterable[str],
520
+ ) -> Tuple[str, bool]:
521
+ key = compute_decision_key(decision_text)
522
+ return key, key in set(existing_keys)
523
+
524
+
525
+ def maybe_archive_jsonl(
526
+ family: str,
527
+ repo_root: Optional[Path] = None,
528
+ scratchpad: Optional[Dict[str, str]] = None,
529
+ ) -> Tuple[bool, Optional[ReasonCode]]:
530
+ if not is_sovereign_memory_enabled(scratchpad):
531
+ return True, None
532
+
533
+ path = resolve_jsonl_path(family, repo_root)
534
+ if not path.is_file():
535
+ return True, None
536
+
537
+ max_lines = parse_scratchpad_int(
538
+ scratchpad,
539
+ SOVEREIGN_MEMORY_JSONL_MAX_LINES_KEY,
540
+ SOVEREIGN_MEMORY_JSONL_MAX_LINES_DEFAULT,
541
+ minimum=1,
542
+ )
543
+ if _count_nonempty_lines(path) < max_lines:
544
+ return True, None
545
+
546
+ archive_dir = resolve_archive_dir(repo_root)
547
+ basename = path.stem
548
+ archive_path = archive_dir / f"{basename}-{_archive_timestamp()}.jsonl"
549
+ try:
550
+ archive_dir.mkdir(parents=True, exist_ok=True)
551
+ if archive_path.exists():
552
+ return True, None
553
+ path.replace(archive_path)
554
+ path.write_text("", encoding="utf-8")
555
+ _fsync_file(path)
556
+ return True, None
557
+ except OSError:
558
+ return False, ReasonCode.SOVEREIGN_MEMORY_ARCHIVE_REQUIRED
559
+
560
+
561
+ def append_decision(
562
+ entry: dict,
563
+ repo_root: Optional[Path] = None,
564
+ scratchpad: Optional[Dict[str, str]] = None,
565
+ ) -> Tuple[bool, Optional[ReasonCode]]:
566
+ if not is_sovereign_memory_enabled(scratchpad):
567
+ return False, ReasonCode.SOVEREIGN_MEMORY_DISABLED
568
+ ok, _ = schema_check(entry, "decisions")
569
+ if not ok:
570
+ return False, ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
571
+
572
+ existing = _load_existing_decision_keys(repo_root)
573
+ _, is_dup = dedupe_decision(str(entry.get("decision_text", "")), existing)
574
+ if is_dup:
575
+ return False, ReasonCode.SOVEREIGN_MEMORY_DECISION_DUPLICATE
576
+
577
+ return _append_jsonl_line("decisions", entry, repo_root, scratchpad)
578
+
579
+
580
+ def append_mistake(
581
+ entry: dict,
582
+ repo_root: Optional[Path] = None,
583
+ scratchpad: Optional[Dict[str, str]] = None,
584
+ ) -> Tuple[bool, Optional[ReasonCode]]:
585
+ if not is_sovereign_memory_enabled(scratchpad):
586
+ return False, ReasonCode.SOVEREIGN_MEMORY_DISABLED
587
+ ok, _ = schema_check(entry, "mistakes")
588
+ if not ok:
589
+ return False, ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
590
+ return _append_jsonl_line("mistakes", entry, repo_root, scratchpad)
591
+
592
+
593
+ def append_pattern(
594
+ entry: dict,
595
+ repo_root: Optional[Path] = None,
596
+ scratchpad: Optional[Dict[str, str]] = None,
597
+ ) -> Tuple[bool, Optional[ReasonCode]]:
598
+ if not is_sovereign_memory_enabled(scratchpad):
599
+ return False, ReasonCode.SOVEREIGN_MEMORY_DISABLED
600
+ ok, _ = schema_check(entry, "patterns")
601
+ if not ok:
602
+ return False, ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
603
+ return _append_jsonl_line("patterns", entry, repo_root, scratchpad)
604
+
605
+
606
+ def append_drift(
607
+ entry: dict,
608
+ repo_root: Optional[Path] = None,
609
+ scratchpad: Optional[Dict[str, str]] = None,
610
+ ) -> Tuple[bool, Optional[ReasonCode]]:
611
+ if not is_sovereign_memory_enabled(scratchpad):
612
+ return False, ReasonCode.SOVEREIGN_MEMORY_DISABLED
613
+ ok, _ = schema_check(entry, "plan-drift")
614
+ if not ok:
615
+ return False, ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
616
+ return _append_jsonl_line("plan-drift", entry, repo_root, scratchpad)
617
+
618
+
619
+ def build_mistake_entry(
620
+ *,
621
+ mistake_tag: str,
622
+ failure_reason_code: str,
623
+ text: str,
624
+ impact_score: int = 50,
625
+ source_orchestrator_run_id: Optional[str] = None,
626
+ source_story_id: Optional[str] = None,
627
+ phase_id: Optional[str] = None,
628
+ provenance_ref: Optional[str] = None,
629
+ tags: Optional[List[str]] = None,
630
+ ) -> dict:
631
+ return {
632
+ "schema_version": SCHEMA_VERSION,
633
+ "ts": _utc_now_iso(),
634
+ "entry_id": str(uuid.uuid4()),
635
+ "source_orchestrator_run_id": source_orchestrator_run_id,
636
+ "source_story_id": source_story_id,
637
+ "phase_id": phase_id,
638
+ "impact_score": impact_score,
639
+ "text": text[:TEXT_MAX_CHARS],
640
+ "tags": tags or ["mistake"],
641
+ "status": "active",
642
+ "provenance_ref": provenance_ref,
643
+ "mistake_tag": mistake_tag,
644
+ "failure_reason_code": failure_reason_code,
645
+ }
646
+
647
+
648
+ def record_mistake_hook(
649
+ mistake_tag: str,
650
+ *,
651
+ text: str,
652
+ repo_root: Optional[Path] = None,
653
+ scratchpad: Optional[Dict[str, str]] = None,
654
+ source_orchestrator_run_id: Optional[str] = None,
655
+ source_story_id: Optional[str] = None,
656
+ phase_id: Optional[str] = None,
657
+ provenance_ref: Optional[str] = None,
658
+ ) -> Tuple[bool, Optional[ReasonCode]]:
659
+ """Orchestrator-detectable mistake hook — no-op when SOVEREIGN_MEMORY=0."""
660
+ if mistake_tag not in MISTAKE_TAG_VALUES:
661
+ return False, ReasonCode.SOVEREIGN_MEMORY_SCHEMA_INVALID
662
+ failure_reason_code = MISTAKE_HOOK_TABLE.get(mistake_tag, mistake_tag.upper())
663
+ entry = build_mistake_entry(
664
+ mistake_tag=mistake_tag,
665
+ failure_reason_code=failure_reason_code,
666
+ text=text,
667
+ source_orchestrator_run_id=source_orchestrator_run_id,
668
+ source_story_id=source_story_id,
669
+ phase_id=phase_id,
670
+ provenance_ref=provenance_ref,
671
+ )
672
+ return append_mistake(entry, repo_root, scratchpad)
673
+
674
+
675
+ def promote_from_ledger(
676
+ orchestrator_run_id: str,
677
+ repo_root: Optional[Path] = None,
678
+ scratchpad: Optional[Dict[str, str]] = None,
679
+ *,
680
+ decision_types: Optional[Sequence[str]] = None,
681
+ ) -> Tuple[List[str], Optional[ReasonCode]]:
682
+ if not is_sovereign_memory_enabled(scratchpad):
683
+ return [], ReasonCode.SOVEREIGN_MEMORY_DISABLED
684
+
685
+ pad = scratchpad or {}
686
+ if pad.get("AI_DECISION_LEDGER", "0").strip() != "1":
687
+ return [], ReasonCode.SOVEREIGN_MEMORY_PROMOTION_SKIPPED
688
+
689
+ try:
690
+ from decision_ledger_lib import ( # noqa: WPS433
691
+ ReasonCode as LedgerReasonCode,
692
+ read_entries as read_ledger_entries,
693
+ resolve_ledger_path,
694
+ )
695
+ except ImportError:
696
+ return [], ReasonCode.SOVEREIGN_MEMORY_PROMOTION_SKIPPED
697
+
698
+ ledger_path = resolve_ledger_path(orchestrator_run_id, repo_root)
699
+ entries, rc, _ = read_ledger_entries(ledger_path)
700
+ if rc == LedgerReasonCode.LEDGER_FILE_MISSING or not entries:
701
+ return [], ReasonCode.SOVEREIGN_MEMORY_PROMOTION_SKIPPED
702
+
703
+ types_filter = set(decision_types) if decision_types else None
704
+ promoted: List[str] = []
705
+ existing_keys = _load_existing_decision_keys(repo_root)
706
+
707
+ for ledger_entry in entries:
708
+ dt = str(ledger_entry.get("decision_type", ""))
709
+ if types_filter is not None and dt not in types_filter:
710
+ continue
711
+ decision_text = str(ledger_entry.get("rationale", "")).strip()
712
+ if not decision_text:
713
+ decision_text = (
714
+ f"{ledger_entry.get('from_artifact', '(none)')} -> "
715
+ f"{ledger_entry.get('to_artifact', '(none)')}"
716
+ )
717
+ key, is_dup = dedupe_decision(decision_text, existing_keys)
718
+ if is_dup:
719
+ continue
720
+
721
+ entry_id = str(uuid.uuid4())
722
+ memory_entry = {
723
+ "schema_version": SCHEMA_VERSION,
724
+ "ts": _utc_now_iso(),
725
+ "entry_id": entry_id,
726
+ "source_orchestrator_run_id": orchestrator_run_id,
727
+ "source_story_id": None,
728
+ "phase_id": ledger_entry.get("phase_id"),
729
+ "impact_score": 60,
730
+ "text": decision_text[:TEXT_MAX_CHARS],
731
+ "tags": ["promoted", "ledger"],
732
+ "status": "active",
733
+ "provenance_ref": f"ledger:{ledger_entry.get('decision_id')}",
734
+ "decision_key": key,
735
+ "decision_text": decision_text[:TEXT_MAX_CHARS],
736
+ "rationale": f"Promoted from per-run ledger decision_type={dt}.",
737
+ }
738
+ ok, reason = append_decision(memory_entry, repo_root, scratchpad)
739
+ if ok:
740
+ promoted.append(entry_id)
741
+ existing_keys.add(key)
742
+
743
+ if not promoted:
744
+ return [], ReasonCode.SOVEREIGN_MEMORY_PROMOTION_SKIPPED
745
+ return promoted, None
746
+
747
+
748
+ def write_retrospective(
749
+ sprint_id: str,
750
+ body: str,
751
+ repo_root: Optional[Path] = None,
752
+ scratchpad: Optional[Dict[str, str]] = None,
753
+ ) -> Tuple[Optional[Path], Optional[ReasonCode]]:
754
+ if not is_sovereign_memory_enabled(scratchpad):
755
+ return None, ReasonCode.SOVEREIGN_MEMORY_DISABLED
756
+
757
+ ok_dir, rc_dir = _ensure_memory_dir(repo_root)
758
+ if not ok_dir:
759
+ return None, rc_dir
760
+
761
+ retro_dir = resolve_retrospectives_dir(repo_root)
762
+ try:
763
+ retro_dir.mkdir(parents=True, exist_ok=True)
764
+ path = retro_dir / f"{sprint_id}.md"
765
+ path.write_text(body, encoding="utf-8")
766
+ _fsync_file(path)
767
+ return path, None
768
+ except OSError:
769
+ return None, ReasonCode.SOVEREIGN_MEMORY_APPEND_FAILED
770
+
771
+
772
+ def build_sample_decision() -> dict:
773
+ decision_text = "Prefer create-on-first-write for sovereign-memory JSONL files"
774
+ return {
775
+ "schema_version": SCHEMA_VERSION,
776
+ "ts": _utc_now_iso(),
777
+ "entry_id": str(uuid.uuid4()),
778
+ "source_orchestrator_run_id": "auto-research-stub",
779
+ "source_story_id": "US-0105",
780
+ "phase_id": "research",
781
+ "impact_score": 70,
782
+ "text": decision_text,
783
+ "tags": ["schema", "bootstrap"],
784
+ "status": "active",
785
+ "provenance_ref": "R-0093",
786
+ "decision_key": compute_decision_key(decision_text),
787
+ "decision_text": decision_text,
788
+ "rationale": "Avoids empty tracked JSONL clutter; directory .gitkeep suffices until first write.",
789
+ }
790
+
791
+
792
+ def build_sample_mistake() -> dict:
793
+ return build_mistake_entry(
794
+ mistake_tag="fix_failed",
795
+ failure_reason_code="FIX_FAILED",
796
+ text="Auto-loop exhausted fix attempts without green QA.",
797
+ source_story_id="US-0105",
798
+ phase_id="execute",
799
+ )
800
+
801
+
802
+ def self_test() -> bool:
803
+ errors: List[str] = []
804
+
805
+ if is_sovereign_memory_enabled({SOVEREIGN_MEMORY_KEY: "0"}):
806
+ errors.append("SOVEREIGN_MEMORY=0 must disable")
807
+ if not is_sovereign_memory_enabled({SOVEREIGN_MEMORY_KEY: "1"}):
808
+ errors.append("SOVEREIGN_MEMORY=1 must enable")
809
+
810
+ sample = build_sample_decision()
811
+ ok, err = schema_check(sample, "decisions")
812
+ if not ok:
813
+ errors.append(f"decisions schema_check failed: {err}")
814
+
815
+ key_a = compute_decision_key("Prefer create-on-first-write")
816
+ key_b = compute_decision_key("prefer create-on-first-write")
817
+ if key_a != key_b:
818
+ errors.append("decision_key normalization mismatch")
819
+
820
+ _, is_dup = dedupe_decision("Prefer create-on-first-write", {key_a})
821
+ if not is_dup:
822
+ errors.append("dedupe_decision should detect duplicate key")
823
+
824
+ disabled = build_injection_digest(scratchpad={SOVEREIGN_MEMORY_KEY: "0"})
825
+ if disabled.digest_text or disabled.entry_ids:
826
+ errors.append("disabled gate must return empty digest")
827
+
828
+ if build_injection_digest_block(scratchpad={SOVEREIGN_MEMORY_KEY: "0"}) is not None:
829
+ errors.append("digest block must be None when disabled")
830
+
831
+ enabled = build_injection_digest(scratchpad={
832
+ SOVEREIGN_MEMORY_KEY: "1",
833
+ SOVEREIGN_MEMORY_MAX_CHARS_KEY: "128",
834
+ })
835
+ if enabled.char_count > 128:
836
+ errors.append("digest must respect SOVEREIGN_MEMORY_MAX_CHARS")
837
+
838
+ secret_sample = build_sample_decision()
839
+ secret_sample["text"] = "api_key=supersecretvalue"
840
+ bad, bad_err = schema_check(secret_sample, "decisions")
841
+ if bad or bad_err != ReasonCode.SOVEREIGN_MEMORY_SECRET_DETECTED.value:
842
+ errors.append("secret scanner must reject api_key patterns")
843
+
844
+ if len(FAMILY_REQUIRED) != 4:
845
+ errors.append("expected four JSONL families")
846
+
847
+ if len(MISTAKE_HOOK_TABLE) != len(MISTAKE_TAG_VALUES):
848
+ errors.append("mistake hook table must cover all tags")
849
+
850
+ if errors:
851
+ for item in errors:
852
+ print(f" {item}", file=sys.stderr)
853
+ print("[SELF_TEST_FAILED]", file=sys.stderr)
854
+ return False
855
+
856
+ print("[SOVEREIGN_MEMORY_SELF_TEST_OK]")
857
+ return True
858
+
859
+
860
+ if __name__ == "__main__":
861
+ import argparse
862
+
863
+ parser = argparse.ArgumentParser(description="Sovereign memory library (US-0105 / DEC-0105)")
864
+ parser.add_argument("--self-test", action="store_true", help="Run self-test")
865
+ args = parser.parse_args()
866
+ if args.self_test:
867
+ sys.exit(0 if self_test() else 1)
868
+ parser.print_help()
869
+ sys.exit(2)