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,828 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sovereign Loop Mode helper library (US-0107 / DEC-0107).
4
+
5
+ Deferral register, drain-generate gate, notification dispatch, and convergence
6
+ hooks composing US-0088/US-0092/US-0095/US-0103/US-0105/US-0110.
7
+
8
+ Sidecar schema v1 — ``handoffs/sovereign_loop_state.json`` (create-on-first-write):
9
+
10
+ {"schema_version": 1, "drain_generate_iterations": {"<orchestrator_run_id>": <int>}}
11
+
12
+ Reason codes (DEC-0107 §9):
13
+ SOVEREIGN_LOOP_DISABLED, SOVEREIGN_LOOP_GOAL_MODE_REQUIRED,
14
+ SOVEREIGN_DEFERRAL_CAP_EXCEEDED, SOVEREIGN_DEFERRAL_SCHEMA_INVALID,
15
+ SOVEREIGN_DEFERRAL_APPEND_FAILED, SOVEREIGN_DRAIN_GENERATE_CAP,
16
+ SOVEREIGN_DRAIN_GENERATE_BLOCKED, SOVEREIGN_NOTIFY_DISPATCH_FAILED,
17
+ SOVEREIGN_NOTIFY_TARGET_INVALID, SOVEREIGN_NOTIFY_CONFIG_MISSING,
18
+ SOVEREIGN_LOOP_ADVANCE_BLOCKED, DEPLOY_DEFERRED
19
+
20
+ Default-off: AUTO_SOVEREIGN=0 → zero overhead (no reads, no writes, no advance).
21
+ Requires SOVEREIGN_GOAL_MODE=goal_convergence when enabled (fail-closed).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import sys
29
+ import uuid
30
+ from dataclasses import asdict, dataclass, field
31
+ from datetime import datetime, timezone
32
+ from enum import Enum
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
35
+ from urllib import error as urllib_error
36
+ from urllib import request as urllib_request
37
+
38
+ _SCRIPT_DIR = Path(__file__).resolve().parent
39
+ if str(_SCRIPT_DIR) not in sys.path:
40
+ sys.path.insert(0, str(_SCRIPT_DIR))
41
+
42
+ from sovereign_convergence_lib import ( # noqa: E402
43
+ SOVEREIGN_GOAL_MODE_KEY,
44
+ check_timeout,
45
+ evaluate_convergence,
46
+ is_goal_convergence_enabled,
47
+ resolve_goal,
48
+ write_partial_delivery_report,
49
+ )
50
+
51
+ SCHEMA_VERSION = 1
52
+ DEFERRALS_PATH = Path("handoffs/sovereign_deferrals.jsonl")
53
+ LOOP_STATE_PATH = Path("handoffs/sovereign_loop_state.json")
54
+
55
+ AUTO_SOVEREIGN_KEY = "AUTO_SOVEREIGN"
56
+ AUTO_SOVEREIGN_DEFERRAL_MAX_KEY = "AUTO_SOVEREIGN_DEFERRAL_MAX"
57
+ AUTO_SOVEREIGN_DRAIN_GENERATE_MAX_KEY = "AUTO_SOVEREIGN_DRAIN_GENERATE_MAX"
58
+ AUTO_SOVEREIGN_DEFERRAL_POLICY_KEY = "AUTO_SOVEREIGN_DEFERRAL_POLICY"
59
+ SOVEREIGN_NOTIFY_TARGET_KEY = "SOVEREIGN_NOTIFY_TARGET"
60
+ SOVEREIGN_NOTIFY_NTFY_TOPIC_KEY = "SOVEREIGN_NOTIFY_NTFY_TOPIC"
61
+ SOVEREIGN_NOTIFY_NTFY_BASE_KEY = "SOVEREIGN_NOTIFY_NTFY_BASE"
62
+ SOVEREIGN_NOTIFY_HOOK_URL_KEY = "SOVEREIGN_NOTIFY_HOOK_URL"
63
+ SOVEREIGN_NOTIFY_EMAIL_TO_KEY = "SOVEREIGN_NOTIFY_EMAIL_TO"
64
+
65
+ AUTO_SOVEREIGN_DEFAULT = "0"
66
+ AUTO_SOVEREIGN_DEFERRAL_MAX_DEFAULT = 50
67
+ AUTO_SOVEREIGN_DRAIN_GENERATE_MAX_DEFAULT = 3
68
+ AUTO_SOVEREIGN_DEFERRAL_POLICY_DEFAULT = "resolve_first"
69
+ SOVEREIGN_NOTIFY_TARGET_DEFAULT = "off"
70
+
71
+ AUTO_SOVEREIGN_VALUES = frozenset({"0", "1"})
72
+ DEFERRAL_POLICY_VALUES = frozenset({"stop", "skip", "resolve_first"})
73
+ NOTIFY_TARGET_VALUES = frozenset({"off", "ntfy", "email", "hook"})
74
+ WORK_ITEM_KIND_VALUES = frozenset({"story", "bug", "deploy", "block"})
75
+ DEFERRAL_STATE_VALUES = frozenset({"open", "resolved", "superseded"})
76
+ STEP_ACTION_VALUES = frozenset({
77
+ "noop",
78
+ "continue",
79
+ "defer",
80
+ "drain_generate",
81
+ "terminal_converged",
82
+ "terminal_timeout",
83
+ "terminal_cap",
84
+ "blocked",
85
+ })
86
+ NOTIFY_EVENT_VALUES = frozenset({
87
+ "convergence",
88
+ "timeout",
89
+ "deferral_cap",
90
+ "drain_generate_cap",
91
+ })
92
+ PROVENANCE_VALUES = frozenset({"vision", "memory", "both"})
93
+
94
+ REMEDIATION_HINT_MAX = 512
95
+ WORK_ITEM_REF_MAX = 128
96
+ TITLE_MAX = 120
97
+ SUMMARY_MAX = 512
98
+ AC_SKETCH_MAX_ITEMS = 8
99
+ AC_SKETCH_ITEM_MAX = 256
100
+ MAX_CANDIDATES_PER_ITERATION = 3
101
+
102
+ REQUIRED_DEFERRAL_FIELDS = frozenset({
103
+ "schema_version",
104
+ "deferral_id",
105
+ "ts",
106
+ "reason_code",
107
+ "work_item_kind",
108
+ "work_item_ref",
109
+ "state",
110
+ "source_orchestrator_run_id",
111
+ "remediation_hint",
112
+ })
113
+
114
+ _SECRET_PATTERNS = (
115
+ re.compile(r"api_key\s*=", re.I),
116
+ re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----"),
117
+ re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
118
+ re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
119
+ )
120
+
121
+
122
+ class ReasonCode(str, Enum):
123
+ SOVEREIGN_LOOP_DISABLED = "SOVEREIGN_LOOP_DISABLED"
124
+ SOVEREIGN_LOOP_GOAL_MODE_REQUIRED = "SOVEREIGN_LOOP_GOAL_MODE_REQUIRED"
125
+ SOVEREIGN_DEFERRAL_CAP_EXCEEDED = "SOVEREIGN_DEFERRAL_CAP_EXCEEDED"
126
+ SOVEREIGN_DEFERRAL_SCHEMA_INVALID = "SOVEREIGN_DEFERRAL_SCHEMA_INVALID"
127
+ SOVEREIGN_DEFERRAL_APPEND_FAILED = "SOVEREIGN_DEFERRAL_APPEND_FAILED"
128
+ SOVEREIGN_DRAIN_GENERATE_CAP = "SOVEREIGN_DRAIN_GENERATE_CAP"
129
+ SOVEREIGN_DRAIN_GENERATE_BLOCKED = "SOVEREIGN_DRAIN_GENERATE_BLOCKED"
130
+ SOVEREIGN_NOTIFY_DISPATCH_FAILED = "SOVEREIGN_NOTIFY_DISPATCH_FAILED"
131
+ SOVEREIGN_NOTIFY_TARGET_INVALID = "SOVEREIGN_NOTIFY_TARGET_INVALID"
132
+ SOVEREIGN_NOTIFY_CONFIG_MISSING = "SOVEREIGN_NOTIFY_CONFIG_MISSING"
133
+ SOVEREIGN_LOOP_ADVANCE_BLOCKED = "SOVEREIGN_LOOP_ADVANCE_BLOCKED"
134
+ DEPLOY_DEFERRED = "DEPLOY_DEFERRED"
135
+
136
+
137
+ REASON_CODES = frozenset(code.value for code in ReasonCode)
138
+
139
+
140
+ def _utc_now_iso() -> str:
141
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
142
+
143
+
144
+ def _scratchpad_int(scratchpad: Dict[str, str], key: str, default: int) -> int:
145
+ raw = scratchpad.get(key, str(default)).strip()
146
+ try:
147
+ return max(0, int(raw))
148
+ except ValueError:
149
+ return default
150
+
151
+
152
+ def scan_secrets(text: str) -> Optional[ReasonCode]:
153
+ for pattern in _SECRET_PATTERNS:
154
+ if pattern.search(text or ""):
155
+ return ReasonCode.SOVEREIGN_DEFERRAL_SCHEMA_INVALID
156
+ return None
157
+
158
+
159
+ def is_sovereign_loop_enabled(scratchpad: Dict[str, str]) -> bool:
160
+ if scratchpad.get(AUTO_SOVEREIGN_KEY, AUTO_SOVEREIGN_DEFAULT).strip() != "1":
161
+ return False
162
+ return is_goal_convergence_enabled(scratchpad)
163
+
164
+
165
+ def resolve_deferrals_path(repo_root: Path) -> Path:
166
+ return repo_root / DEFERRALS_PATH
167
+
168
+
169
+ def schema_check_deferral(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
170
+ if not isinstance(entry, dict):
171
+ return False, "entry must be object"
172
+
173
+ missing = REQUIRED_DEFERRAL_FIELDS - set(entry.keys())
174
+ if missing:
175
+ return False, f"missing required fields: {sorted(missing)}"
176
+
177
+ if entry.get("schema_version") != SCHEMA_VERSION:
178
+ return False, "schema_version must be 1"
179
+
180
+ if entry.get("work_item_kind") not in WORK_ITEM_KIND_VALUES:
181
+ return False, "invalid work_item_kind"
182
+
183
+ if entry.get("state") not in DEFERRAL_STATE_VALUES:
184
+ return False, "invalid state"
185
+
186
+ hint = str(entry.get("remediation_hint", ""))
187
+ if not hint or len(hint) > REMEDIATION_HINT_MAX:
188
+ return False, "remediation_hint length invalid"
189
+
190
+ ref = str(entry.get("work_item_ref", ""))
191
+ if not ref or len(ref) > WORK_ITEM_REF_MAX:
192
+ return False, "work_item_ref length invalid"
193
+
194
+ for field_name in ("remediation_hint", "work_item_ref"):
195
+ if scan_secrets(str(entry.get(field_name, ""))):
196
+ return False, ReasonCode.SOVEREIGN_DEFERRAL_SCHEMA_INVALID.value
197
+
198
+ return True, None
199
+
200
+
201
+ def schema_check_drain_generate_candidate(candidate: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
202
+ if not isinstance(candidate, dict):
203
+ return False, "candidate must be object"
204
+ title = str(candidate.get("title", ""))
205
+ summary = str(candidate.get("summary", ""))
206
+ if not title or len(title) > TITLE_MAX:
207
+ return False, "title length invalid"
208
+ if not summary or len(summary) > SUMMARY_MAX:
209
+ return False, "summary length invalid"
210
+ ac_sketch = candidate.get("ac_sketch", [])
211
+ if not isinstance(ac_sketch, list) or len(ac_sketch) > AC_SKETCH_MAX_ITEMS:
212
+ return False, "ac_sketch invalid"
213
+ for item in ac_sketch:
214
+ if len(str(item)) > AC_SKETCH_ITEM_MAX:
215
+ return False, "ac_sketch item too long"
216
+ provenance = candidate.get("provenance", "vision")
217
+ if provenance not in PROVENANCE_VALUES:
218
+ return False, "invalid provenance"
219
+ return True, None
220
+
221
+
222
+ def schema_check_drain_generate_bundle(bundle: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
223
+ if not isinstance(bundle, dict):
224
+ return False, "bundle must be object"
225
+ if bundle.get("schema_version") != SCHEMA_VERSION:
226
+ return False, "schema_version must be 1"
227
+ candidates = bundle.get("candidates", [])
228
+ if not isinstance(candidates, list):
229
+ return False, "candidates must be list"
230
+ if len(candidates) > MAX_CANDIDATES_PER_ITERATION:
231
+ return False, "candidate cap exceeded"
232
+ for candidate in candidates:
233
+ ok, err = schema_check_drain_generate_candidate(candidate)
234
+ if not ok:
235
+ return False, err
236
+ return True, None
237
+
238
+
239
+ def _read_deferral_rows(path: Path) -> List[Dict[str, Any]]:
240
+ if not path.is_file():
241
+ return []
242
+ rows: List[Dict[str, Any]] = []
243
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
244
+ if not raw.strip():
245
+ continue
246
+ try:
247
+ obj = json.loads(raw)
248
+ except json.JSONDecodeError:
249
+ continue
250
+ if isinstance(obj, dict):
251
+ rows.append(obj)
252
+ return rows
253
+
254
+
255
+ def list_open_deferrals(
256
+ repo: Path,
257
+ *,
258
+ scratchpad: Optional[Dict[str, str]] = None,
259
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
260
+ if scratchpad is not None and not is_sovereign_loop_enabled(scratchpad):
261
+ return [], ReasonCode.SOVEREIGN_LOOP_DISABLED.value
262
+
263
+ latest: Dict[str, Dict[str, Any]] = {}
264
+ for row in _read_deferral_rows(resolve_deferrals_path(repo)):
265
+ deferral_id = str(row.get("deferral_id", "")).strip()
266
+ if not deferral_id:
267
+ continue
268
+ latest[deferral_id] = row
269
+
270
+ open_rows = [
271
+ row for row in latest.values()
272
+ if str(row.get("state", "")).lower() == "open"
273
+ ]
274
+ open_rows.sort(key=lambda r: (str(r.get("ts", "")), str(r.get("deferral_id", ""))))
275
+ return open_rows, None
276
+
277
+
278
+ def build_sample_deferral() -> Dict[str, Any]:
279
+ return {
280
+ "schema_version": SCHEMA_VERSION,
281
+ "deferral_id": str(uuid.uuid4()),
282
+ "ts": _utc_now_iso(),
283
+ "reason_code": ReasonCode.DEPLOY_DEFERRED.value,
284
+ "work_item_kind": "deploy",
285
+ "work_item_ref": "release-target:staging",
286
+ "state": "open",
287
+ "source_orchestrator_run_id": "auto-research-stub",
288
+ "remediation_hint": "Retry deploy smoke after US-0109 ships bounded repair loop.",
289
+ "blocked_by_phase": "release",
290
+ "retry_count": 0,
291
+ }
292
+
293
+
294
+ @dataclass
295
+ class DrainGenerateCandidate:
296
+ candidate_id: str
297
+ title: str
298
+ summary: str
299
+ ac_sketch: List[str] = field(default_factory=list)
300
+ plan_area_id: Optional[str] = None
301
+ priority: str = "P2"
302
+ provenance: str = "vision"
303
+
304
+
305
+ @dataclass
306
+ class DrainGenerateCandidateBundle:
307
+ schema_version: int = SCHEMA_VERSION
308
+ orchestrator_run_id: str = ""
309
+ iteration: int = 0
310
+ generated_at: str = ""
311
+ candidates: List[DrainGenerateCandidate] = field(default_factory=list)
312
+
313
+ def to_dict(self) -> Dict[str, Any]:
314
+ payload = asdict(self)
315
+ payload["candidates"] = [asdict(c) for c in self.candidates]
316
+ return payload
317
+
318
+
319
+ @dataclass
320
+ class SovereignLoopStepResult:
321
+ schema_version: int = SCHEMA_VERSION
322
+ action: str = "noop"
323
+ reason_code: Optional[str] = None
324
+ stop_reason: Optional[str] = None
325
+ orchestrator_run_id: str = ""
326
+ evaluated_at: str = ""
327
+ deferral_id: Optional[str] = None
328
+ drain_generate_bundle: Optional[Dict[str, Any]] = None
329
+ notification_dispatched: bool = False
330
+ convergence: Optional[Dict[str, Any]] = None
331
+
332
+ def to_dict(self) -> Dict[str, Any]:
333
+ return asdict(self)
334
+
335
+
336
+ def append_deferral(
337
+ repo: Path,
338
+ scratchpad: Dict[str, str],
339
+ *,
340
+ reason_code: str,
341
+ work_item_kind: str,
342
+ work_item_ref: str,
343
+ source_orchestrator_run_id: str,
344
+ remediation_hint: str,
345
+ blocked_by_phase: Optional[str] = None,
346
+ retry_count: Optional[int] = None,
347
+ ledger_decision_id: Optional[str] = None,
348
+ ) -> Tuple[Optional[str], Optional[str]]:
349
+ if not is_sovereign_loop_enabled(scratchpad):
350
+ return None, ReasonCode.SOVEREIGN_LOOP_DISABLED.value
351
+
352
+ cap = _scratchpad_int(scratchpad, AUTO_SOVEREIGN_DEFERRAL_MAX_KEY, AUTO_SOVEREIGN_DEFERRAL_MAX_DEFAULT)
353
+ open_rows, _ = list_open_deferrals(repo, scratchpad=scratchpad)
354
+ if len(open_rows) >= cap:
355
+ return None, ReasonCode.SOVEREIGN_DEFERRAL_CAP_EXCEEDED.value
356
+
357
+ entry: Dict[str, Any] = {
358
+ "schema_version": SCHEMA_VERSION,
359
+ "deferral_id": str(uuid.uuid4()),
360
+ "ts": _utc_now_iso(),
361
+ "reason_code": reason_code,
362
+ "work_item_kind": work_item_kind,
363
+ "work_item_ref": work_item_ref,
364
+ "state": "open",
365
+ "source_orchestrator_run_id": source_orchestrator_run_id,
366
+ "remediation_hint": remediation_hint,
367
+ }
368
+ if blocked_by_phase:
369
+ entry["blocked_by_phase"] = blocked_by_phase
370
+ if retry_count is not None:
371
+ entry["retry_count"] = retry_count
372
+ if ledger_decision_id:
373
+ entry["ledger_decision_id"] = ledger_decision_id
374
+
375
+ ok, err = schema_check_deferral(entry)
376
+ if not ok:
377
+ return None, err or ReasonCode.SOVEREIGN_DEFERRAL_SCHEMA_INVALID.value
378
+
379
+ path = resolve_deferrals_path(repo)
380
+ try:
381
+ path.parent.mkdir(parents=True, exist_ok=True)
382
+ with path.open("a", encoding="utf-8") as handle:
383
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
384
+ except OSError:
385
+ return None, ReasonCode.SOVEREIGN_DEFERRAL_APPEND_FAILED.value
386
+
387
+ return str(entry["deferral_id"]), None
388
+
389
+
390
+ def resolve_deferral(
391
+ repo: Path,
392
+ deferral_id: str,
393
+ *,
394
+ orchestrator_run_id: str,
395
+ ) -> Tuple[bool, Optional[str]]:
396
+ rows = _read_deferral_rows(resolve_deferrals_path(repo))
397
+ latest = None
398
+ for row in rows:
399
+ if str(row.get("deferral_id", "")) == deferral_id:
400
+ latest = row
401
+ if latest is None:
402
+ return False, "deferral_id not found"
403
+ if str(latest.get("state", "")).lower() != "open":
404
+ return False, "deferral not open"
405
+
406
+ resolved = dict(latest)
407
+ resolved["ts"] = _utc_now_iso()
408
+ resolved["state"] = "resolved"
409
+ resolved["source_orchestrator_run_id"] = orchestrator_run_id
410
+
411
+ ok, err = schema_check_deferral(resolved)
412
+ if not ok:
413
+ return False, err
414
+
415
+ path = resolve_deferrals_path(repo)
416
+ try:
417
+ with path.open("a", encoding="utf-8") as handle:
418
+ handle.write(json.dumps(resolved, ensure_ascii=False) + "\n")
419
+ except OSError:
420
+ return False, ReasonCode.SOVEREIGN_DEFERRAL_APPEND_FAILED.value
421
+ return True, None
422
+
423
+
424
+ def _read_loop_state(repo: Path) -> Dict[str, Any]:
425
+ state_path = repo / LOOP_STATE_PATH
426
+ if not state_path.is_file():
427
+ return {"schema_version": SCHEMA_VERSION, "drain_generate_iterations": {}}
428
+ try:
429
+ data = json.loads(state_path.read_text(encoding="utf-8"))
430
+ except (OSError, json.JSONDecodeError):
431
+ return {"schema_version": SCHEMA_VERSION, "drain_generate_iterations": {}}
432
+ if not isinstance(data, dict):
433
+ return {"schema_version": SCHEMA_VERSION, "drain_generate_iterations": {}}
434
+ runs = data.get("drain_generate_iterations", {})
435
+ if not isinstance(runs, dict):
436
+ runs = {}
437
+ return {"schema_version": SCHEMA_VERSION, "drain_generate_iterations": runs}
438
+
439
+
440
+ def _write_loop_state(repo: Path, state: Dict[str, Any]) -> None:
441
+ state_path = repo / LOOP_STATE_PATH
442
+ state_path.parent.mkdir(parents=True, exist_ok=True)
443
+ state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
444
+
445
+
446
+ def count_drain_generate_iterations(repo: Path, orchestrator_run_id: str) -> int:
447
+ runs = _read_loop_state(repo).get("drain_generate_iterations", {})
448
+ try:
449
+ return int(runs.get(orchestrator_run_id, 0))
450
+ except (TypeError, ValueError):
451
+ return 0
452
+
453
+
454
+ def increment_drain_generate_iterations(repo: Path, orchestrator_run_id: str) -> int:
455
+ state = _read_loop_state(repo)
456
+ runs = state.setdefault("drain_generate_iterations", {})
457
+ current = count_drain_generate_iterations(repo, orchestrator_run_id)
458
+ new_count = current + 1
459
+ runs[orchestrator_run_id] = new_count
460
+ _write_loop_state(repo, state)
461
+ return new_count
462
+
463
+
464
+ def build_drain_generate_spawn_inputs(
465
+ repo: Path,
466
+ scratchpad: Dict[str, str],
467
+ convergence: Any,
468
+ ) -> Dict[str, Any]:
469
+ goal = resolve_goal(scratchpad, repo)
470
+ inputs: Dict[str, Any] = {
471
+ "vision_path": "docs/product/vision.md",
472
+ "unmet_conditions": getattr(convergence, "unmet_conditions", []),
473
+ "blocked_by": getattr(convergence, "blocked_by", []),
474
+ "goal_text": goal.goal_text or "",
475
+ }
476
+ if scratchpad.get("SOVEREIGN_MEMORY", "0").strip() == "1":
477
+ try:
478
+ from sovereign_memory_lib import build_injection_digest_block # noqa: WPS433
479
+
480
+ block = build_injection_digest_block(scratchpad=scratchpad, repo_root=repo)
481
+ if block:
482
+ inputs["sovereign_memory_digest"] = block
483
+ except ImportError:
484
+ inputs["sovereign_memory_digest"] = None
485
+ return inputs
486
+
487
+
488
+ def build_drain_generate_ephemeral_id(orchestrator_run_id: str, iteration: int) -> str:
489
+ return f"drain-gen-{orchestrator_run_id}-{iteration}"
490
+
491
+
492
+ def _build_notification_payload(
493
+ *,
494
+ event_type: str,
495
+ orchestrator_run_id: str,
496
+ reason_code: Optional[str],
497
+ convergence: Any = None,
498
+ ) -> Dict[str, Any]:
499
+ goal_progress = None
500
+ unmet: List[str] = []
501
+ blocked: List[str] = []
502
+ if convergence is not None:
503
+ if hasattr(convergence, "to_dict"):
504
+ goal_progress = convergence.to_dict()
505
+ unmet = list(getattr(convergence, "unmet_conditions", []))
506
+ blocked = list(getattr(convergence, "blocked_by", []))
507
+ elif isinstance(convergence, dict):
508
+ goal_progress = convergence
509
+ unmet = list(convergence.get("unmet_conditions", []))
510
+ blocked = list(convergence.get("blocked_by", []))
511
+ return {
512
+ "schema_version": SCHEMA_VERSION,
513
+ "event_type": event_type,
514
+ "ts": _utc_now_iso(),
515
+ "orchestrator_run_id": orchestrator_run_id,
516
+ "reason_code": reason_code,
517
+ "unmet_conditions": unmet,
518
+ "blocked_by": blocked,
519
+ "goal_progress": goal_progress,
520
+ }
521
+
522
+
523
+ def dispatch_notification(
524
+ scratchpad: Dict[str, str],
525
+ event_type: str,
526
+ payload: Dict[str, Any],
527
+ ) -> Tuple[bool, Optional[str]]:
528
+ target = scratchpad.get(SOVEREIGN_NOTIFY_TARGET_KEY, SOVEREIGN_NOTIFY_TARGET_DEFAULT).strip().lower()
529
+ if target == "off":
530
+ return True, None
531
+ if event_type not in NOTIFY_EVENT_VALUES:
532
+ return False, ReasonCode.SOVEREIGN_NOTIFY_TARGET_INVALID.value
533
+ if target == "email":
534
+ return False, ReasonCode.SOVEREIGN_NOTIFY_TARGET_INVALID.value
535
+ if target == "ntfy" and not scratchpad.get(SOVEREIGN_NOTIFY_NTFY_TOPIC_KEY, "").strip():
536
+ return True, ReasonCode.SOVEREIGN_NOTIFY_CONFIG_MISSING.value
537
+ if target == "hook" and not scratchpad.get(SOVEREIGN_NOTIFY_HOOK_URL_KEY, "").strip():
538
+ return True, ReasonCode.SOVEREIGN_NOTIFY_CONFIG_MISSING.value
539
+
540
+ try:
541
+ if target == "ntfy":
542
+ topic = scratchpad.get(SOVEREIGN_NOTIFY_NTFY_TOPIC_KEY, "").strip()
543
+ base = scratchpad.get(SOVEREIGN_NOTIFY_NTFY_BASE_KEY, "").strip() or "https://ntfy.sh"
544
+ url = f"{base.rstrip('/')}/{topic}"
545
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
546
+ req = urllib_request.Request(url, data=body, method="POST")
547
+ req.add_header("Title", f"Sovereign {event_type}")
548
+ priority = "4" if event_type in ("timeout", "deferral_cap", "drain_generate_cap") else "3"
549
+ req.add_header("Priority", priority)
550
+ req.add_header("Tags", "sovereign,auto")
551
+ urllib_request.urlopen(req, timeout=10)
552
+ elif target == "hook":
553
+ hook_url = scratchpad.get(SOVEREIGN_NOTIFY_HOOK_URL_KEY, "").strip()
554
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
555
+ req = urllib_request.Request(hook_url, data=body, method="POST")
556
+ req.add_header("Content-Type", "application/json")
557
+ urllib_request.urlopen(req, timeout=10)
558
+ except (OSError, urllib_error.URLError, urllib_error.HTTPError, ValueError):
559
+ print(
560
+ f"[{ReasonCode.SOVEREIGN_NOTIFY_DISPATCH_FAILED.value}] adapter error for {event_type}",
561
+ file=sys.stderr,
562
+ )
563
+ return True, None
564
+
565
+ return True, None
566
+
567
+
568
+ def advance_sovereign_loop(
569
+ repo: Path,
570
+ scratchpad: Dict[str, str],
571
+ *,
572
+ orchestrator_run_id: str,
573
+ iteration: Optional[int] = None,
574
+ ) -> SovereignLoopStepResult:
575
+ evaluated_at = _utc_now_iso()
576
+ if scratchpad.get(AUTO_SOVEREIGN_KEY, AUTO_SOVEREIGN_DEFAULT).strip() != "1":
577
+ return SovereignLoopStepResult(
578
+ action="noop",
579
+ orchestrator_run_id=orchestrator_run_id,
580
+ evaluated_at=evaluated_at,
581
+ )
582
+
583
+ if not is_goal_convergence_enabled(scratchpad):
584
+ return SovereignLoopStepResult(
585
+ action="blocked",
586
+ reason_code=ReasonCode.SOVEREIGN_LOOP_GOAL_MODE_REQUIRED.value,
587
+ stop_reason=ReasonCode.SOVEREIGN_LOOP_GOAL_MODE_REQUIRED.value,
588
+ orchestrator_run_id=orchestrator_run_id,
589
+ evaluated_at=evaluated_at,
590
+ )
591
+
592
+ iter_count = iteration if iteration is not None else 0
593
+ convergence = evaluate_convergence(
594
+ repo,
595
+ scratchpad,
596
+ orchestrator_run_id=orchestrator_run_id,
597
+ iteration=iteration,
598
+ )
599
+ conv_dict = convergence.to_dict()
600
+
601
+ if convergence.converged:
602
+ payload = _build_notification_payload(
603
+ event_type="convergence",
604
+ orchestrator_run_id=orchestrator_run_id,
605
+ reason_code="converged",
606
+ convergence=convergence,
607
+ )
608
+ _, _ = dispatch_notification(scratchpad, "convergence", payload)
609
+ return SovereignLoopStepResult(
610
+ action="terminal_converged",
611
+ stop_reason="converged",
612
+ orchestrator_run_id=orchestrator_run_id,
613
+ evaluated_at=evaluated_at,
614
+ notification_dispatched=True,
615
+ convergence=conv_dict,
616
+ )
617
+
618
+ timed_out, timeout_reason = check_timeout(scratchpad, iter_count)
619
+ if timed_out and timeout_reason is not None:
620
+ goal = resolve_goal(scratchpad, repo)
621
+ write_partial_delivery_report(
622
+ repo,
623
+ convergence,
624
+ goal.goal_text or "",
625
+ timeout_reason.value,
626
+ orchestrator_run_id,
627
+ )
628
+ payload = _build_notification_payload(
629
+ event_type="timeout",
630
+ orchestrator_run_id=orchestrator_run_id,
631
+ reason_code=timeout_reason.value,
632
+ convergence=convergence,
633
+ )
634
+ _, _ = dispatch_notification(scratchpad, "timeout", payload)
635
+ return SovereignLoopStepResult(
636
+ action="terminal_timeout",
637
+ reason_code=timeout_reason.value,
638
+ stop_reason=timeout_reason.value,
639
+ orchestrator_run_id=orchestrator_run_id,
640
+ evaluated_at=evaluated_at,
641
+ notification_dispatched=True,
642
+ convergence=conv_dict,
643
+ )
644
+
645
+ open_deferrals, _ = list_open_deferrals(repo, scratchpad=scratchpad)
646
+ policy = scratchpad.get(
647
+ AUTO_SOVEREIGN_DEFERRAL_POLICY_KEY,
648
+ AUTO_SOVEREIGN_DEFERRAL_POLICY_DEFAULT,
649
+ ).strip()
650
+
651
+ deferral_cap = _scratchpad_int(
652
+ scratchpad,
653
+ AUTO_SOVEREIGN_DEFERRAL_MAX_KEY,
654
+ AUTO_SOVEREIGN_DEFERRAL_MAX_DEFAULT,
655
+ )
656
+ if len(open_deferrals) >= deferral_cap:
657
+ payload = _build_notification_payload(
658
+ event_type="deferral_cap",
659
+ orchestrator_run_id=orchestrator_run_id,
660
+ reason_code=ReasonCode.SOVEREIGN_DEFERRAL_CAP_EXCEEDED.value,
661
+ convergence=convergence,
662
+ )
663
+ _, _ = dispatch_notification(scratchpad, "deferral_cap", payload)
664
+ return SovereignLoopStepResult(
665
+ action="terminal_cap",
666
+ reason_code=ReasonCode.SOVEREIGN_DEFERRAL_CAP_EXCEEDED.value,
667
+ stop_reason=ReasonCode.SOVEREIGN_DEFERRAL_CAP_EXCEEDED.value,
668
+ orchestrator_run_id=orchestrator_run_id,
669
+ evaluated_at=evaluated_at,
670
+ notification_dispatched=True,
671
+ convergence=conv_dict,
672
+ )
673
+
674
+ if open_deferrals and policy == "stop":
675
+ return SovereignLoopStepResult(
676
+ action="defer",
677
+ reason_code=ReasonCode.SOVEREIGN_LOOP_ADVANCE_BLOCKED.value,
678
+ deferral_id=str(open_deferrals[0].get("deferral_id", "")),
679
+ orchestrator_run_id=orchestrator_run_id,
680
+ evaluated_at=evaluated_at,
681
+ convergence=conv_dict,
682
+ )
683
+
684
+ if open_deferrals and policy == "resolve_first":
685
+ return SovereignLoopStepResult(
686
+ action="blocked",
687
+ reason_code=ReasonCode.SOVEREIGN_LOOP_ADVANCE_BLOCKED.value,
688
+ stop_reason=ReasonCode.SOVEREIGN_LOOP_ADVANCE_BLOCKED.value,
689
+ orchestrator_run_id=orchestrator_run_id,
690
+ evaluated_at=evaluated_at,
691
+ convergence=conv_dict,
692
+ )
693
+
694
+ backlog_conj = convergence.conjuncts.get("backlog_clear")
695
+ if backlog_conj and backlog_conj.status == "fail":
696
+ return SovereignLoopStepResult(
697
+ action="continue",
698
+ orchestrator_run_id=orchestrator_run_id,
699
+ evaluated_at=evaluated_at,
700
+ convergence=conv_dict,
701
+ )
702
+
703
+ drain_max = _scratchpad_int(
704
+ scratchpad,
705
+ AUTO_SOVEREIGN_DRAIN_GENERATE_MAX_KEY,
706
+ AUTO_SOVEREIGN_DRAIN_GENERATE_MAX_DEFAULT,
707
+ )
708
+ current_iter = count_drain_generate_iterations(repo, orchestrator_run_id)
709
+ if current_iter < drain_max:
710
+ new_iter = increment_drain_generate_iterations(repo, orchestrator_run_id)
711
+ bundle = DrainGenerateCandidateBundle(
712
+ orchestrator_run_id=orchestrator_run_id,
713
+ iteration=new_iter,
714
+ generated_at=evaluated_at,
715
+ candidates=[],
716
+ )
717
+ return SovereignLoopStepResult(
718
+ action="drain_generate",
719
+ orchestrator_run_id=orchestrator_run_id,
720
+ evaluated_at=evaluated_at,
721
+ drain_generate_bundle=bundle.to_dict(),
722
+ convergence=conv_dict,
723
+ )
724
+
725
+ payload = _build_notification_payload(
726
+ event_type="drain_generate_cap",
727
+ orchestrator_run_id=orchestrator_run_id,
728
+ reason_code=ReasonCode.SOVEREIGN_DRAIN_GENERATE_CAP.value,
729
+ convergence=convergence,
730
+ )
731
+ _, _ = dispatch_notification(scratchpad, "drain_generate_cap", payload)
732
+ return SovereignLoopStepResult(
733
+ action="terminal_cap",
734
+ reason_code=ReasonCode.SOVEREIGN_DRAIN_GENERATE_CAP.value,
735
+ stop_reason=ReasonCode.SOVEREIGN_DRAIN_GENERATE_CAP.value,
736
+ orchestrator_run_id=orchestrator_run_id,
737
+ evaluated_at=evaluated_at,
738
+ notification_dispatched=True,
739
+ convergence=conv_dict,
740
+ )
741
+
742
+
743
+ def self_test() -> bool:
744
+ errors: List[str] = []
745
+
746
+ if is_sovereign_loop_enabled({AUTO_SOVEREIGN_KEY: "0", SOVEREIGN_GOAL_MODE_KEY: "goal_convergence"}):
747
+ errors.append("AUTO_SOVEREIGN=0 must disable even with goal_convergence")
748
+
749
+ if not is_sovereign_loop_enabled({AUTO_SOVEREIGN_KEY: "1", SOVEREIGN_GOAL_MODE_KEY: "goal_convergence"}):
750
+ errors.append("AUTO_SOVEREIGN=1 + goal_convergence must enable")
751
+
752
+ if is_sovereign_loop_enabled({AUTO_SOVEREIGN_KEY: "1", SOVEREIGN_GOAL_MODE_KEY: "phase_driven"}):
753
+ errors.append("phase_driven must fail-closed for sovereign loop")
754
+
755
+ sample = build_sample_deferral()
756
+ ok, err = schema_check_deferral(sample)
757
+ if not ok:
758
+ errors.append(f"sample deferral invalid: {err}")
759
+
760
+ bad_secret = build_sample_deferral()
761
+ bad_secret["remediation_hint"] = "api_key=leaked"
762
+ bad_ok, bad_err = schema_check_deferral(bad_secret)
763
+ if bad_ok:
764
+ errors.append("secret scan must reject api_key patterns")
765
+
766
+ step = advance_sovereign_loop(
767
+ Path("."),
768
+ {AUTO_SOVEREIGN_KEY: "0"},
769
+ orchestrator_run_id="self-test",
770
+ )
771
+ if step.action != "noop":
772
+ errors.append("disabled advance must noop")
773
+
774
+ blocked = advance_sovereign_loop(
775
+ Path("."),
776
+ {AUTO_SOVEREIGN_KEY: "1", SOVEREIGN_GOAL_MODE_KEY: "phase_driven"},
777
+ orchestrator_run_id="self-test",
778
+ )
779
+ if blocked.action != "blocked" or blocked.reason_code != ReasonCode.SOVEREIGN_LOOP_GOAL_MODE_REQUIRED.value:
780
+ errors.append("goal mode coupling must fail-closed")
781
+
782
+ notify_ok, _ = dispatch_notification(
783
+ {SOVEREIGN_NOTIFY_TARGET_KEY: "off"},
784
+ "convergence",
785
+ {"schema_version": 1},
786
+ )
787
+ if not notify_ok:
788
+ errors.append("off target must fail-open success")
789
+
790
+ email_ok, email_code = dispatch_notification(
791
+ {SOVEREIGN_NOTIFY_TARGET_KEY: "email"},
792
+ "convergence",
793
+ {"schema_version": 1},
794
+ )
795
+ if email_ok or email_code != ReasonCode.SOVEREIGN_NOTIFY_TARGET_INVALID.value:
796
+ errors.append("email target must return SOVEREIGN_NOTIFY_TARGET_INVALID")
797
+
798
+ bundle = DrainGenerateCandidateBundle(
799
+ orchestrator_run_id="self-test",
800
+ iteration=1,
801
+ generated_at=_utc_now_iso(),
802
+ candidates=[
803
+ DrainGenerateCandidate(
804
+ candidate_id=str(uuid.uuid4()),
805
+ title="Example follow-on story",
806
+ summary="Bounded drain-generate candidate for self-test.",
807
+ ac_sketch=["AC-1: example"],
808
+ ),
809
+ ],
810
+ )
811
+ bundle_ok, bundle_err = schema_check_drain_generate_bundle(bundle.to_dict())
812
+ if not bundle_ok:
813
+ errors.append(f"bundle schema failed: {bundle_err}")
814
+
815
+ if len(REASON_CODES) != 12:
816
+ errors.append("reason code inventory must be 12 codes")
817
+
818
+ if errors:
819
+ for msg in errors:
820
+ print(f"[SOVEREIGN_LOOP_SELF_TEST_FAIL] {msg}", file=sys.stderr)
821
+ return False
822
+
823
+ print("[SOVEREIGN_LOOP_SELF_TEST_OK]")
824
+ return True
825
+
826
+
827
+ if __name__ == "__main__":
828
+ raise SystemExit(0 if self_test() else 1)