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,463 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Self-Healing Deploy Loop helper library (US-0109 / DEC-0109).
4
+
5
+ Post-deploy smoke probe + bounded retry loop on US-0054 publish chain.
6
+ After the post-publish PASS point, two-stage probe validates deployed artifact.
7
+ On probe FAIL, re-enter publish PASS path (not execute re-entry) up to
8
+ `AUTO_SOVEREIGN_DEPLOY_RETRY_MAX`. After retry-cap exhaustion, emit
9
+ DEPLOY_DEFERRED tuple via US-0107 `append_deferral(work_item_kind=deploy)`.
10
+
11
+ Default-off: `AUTO_SOVEREIGN_SELF_HEALING_DEPLOY=0` → zero overhead, byte-identical
12
+ US-0054 publish path — no probe, no retry, no deferral, no execute steps 29-31.
13
+
14
+ Reason codes (DEC-0109 §7):
15
+ DEPLOY_HEALING_DISABLED (info), DEPLOY_HEALING_SMOKE_HEALTH_FAIL,
16
+ DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL, DEPLOY_HEALING_RETRY_ATTEMPT,
17
+ DEPLOY_HEALING_RETRY_CAP_EXHAUSTED, DEPLOY_HEALING_DEFERRED,
18
+ DEPLOY_HEALING_PROBE_TARGET_MISSING, DEPLOY_HEALING_TIMEOUT.
19
+
20
+ Compose guards (non-negotiable): DO NOT amend US-0054, US-0100, US-0103, US-0107, US-0110.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import socket
28
+ import subprocess
29
+ import sys
30
+ import time
31
+ from dataclasses import asdict, dataclass, field
32
+ from datetime import datetime, timezone
33
+ from enum import Enum
34
+ from pathlib import Path
35
+ from typing import Any, Callable, Dict, List, Optional, Tuple
36
+
37
+ _SCRIPT_DIR = Path(__file__).resolve().parent
38
+ if str(_SCRIPT_DIR) not in sys.path:
39
+ sys.path.insert(0, str(_SCRIPT_DIR))
40
+
41
+ try:
42
+ from sovereign_loop_lib import append_deferral, is_sovereign_loop_enabled # type: ignore
43
+ except ImportError:
44
+ append_deferral = None # type: ignore
45
+ is_sovereign_loop_enabled = None # type: ignore
46
+
47
+
48
+ SCHEMA_VERSION = 1
49
+
50
+
51
+ class ProbeKind(Enum):
52
+ HEALTH_ENDPOINT = "health_endpoint"
53
+ ACCEPTANCE_SMOKE = "acceptance_smoke"
54
+ BOTH = "both"
55
+
56
+
57
+ class ReasonCode(Enum):
58
+ DEPLOY_HEALING_DISABLED = "DEPLOY_HEALING_DISABLED"
59
+ DEPLOY_HEALING_SMOKE_HEALTH_FAIL = "DEPLOY_HEALING_SMOKE_HEALTH_FAIL"
60
+ DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL = "DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL"
61
+ DEPLOY_HEALING_RETRY_ATTEMPT = "DEPLOY_HEALING_RETRY_ATTEMPT"
62
+ DEPLOY_HEALING_RETRY_CAP_EXHAUSTED = "DEPLOY_HEALING_RETRY_CAP_EXHAUSTED"
63
+ DEPLOY_HEALING_DEFERRED = "DEPLOY_HEALING_DEFERRED"
64
+ DEPLOY_HEALING_PROBE_TARGET_MISSING = "DEPLOY_HEALING_PROBE_TARGET_MISSING"
65
+ DEPLOY_HEALING_TIMEOUT = "DEPLOY_HEALING_TIMEOUT"
66
+
67
+
68
+ # Scratchpad keys (DEC-0109 §1)
69
+ AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY = "AUTO_SOVEREIGN_SELF_HEALING_DEPLOY"
70
+ AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY = "AUTO_SOVEREIGN_DEPLOY_RETRY_MAX"
71
+ AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY = "AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC"
72
+ AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY = "AUTO_SOVEREIGN_DEPLOY_PROBE_KIND"
73
+ SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY = "SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH"
74
+ AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY = "AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT"
75
+
76
+ # Defaults (DEC-0109 §1)
77
+ DEFAULT_RETRY_MAX = 3
78
+ DEFAULT_SMOKE_TIMEOUT_SEC = 30
79
+ DEFAULT_PROBE_KIND = "both"
80
+ DEFAULT_ACCEPTANCE_SMOKE_PATH = "tests/deploy_smoke/"
81
+ DEFAULT_SELF_HEALING_ENABLED = "0"
82
+
83
+
84
+ @dataclass
85
+ class ProbeResult:
86
+ probe_kind: str
87
+ health_status: Optional[str] = None
88
+ health_status_code: Optional[str] = None
89
+ acceptance_status: Optional[str] = None
90
+ acceptance_tests_run: Optional[int] = None
91
+ acceptance_tests_failed: Optional[int] = None
92
+ overall: str = "fail"
93
+ reason_code: str = ""
94
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
95
+
96
+ def to_dict(self) -> Dict[str, Any]:
97
+ return asdict(self)
98
+
99
+
100
+ @dataclass
101
+ class HealingLoopResult:
102
+ enabled: bool
103
+ probe_result: Optional[Dict[str, Any]] = None
104
+ retry_count: int = 0
105
+ retry_max: int = DEFAULT_RETRY_MAX
106
+ deferred: bool = False
107
+ deferral_id: Optional[str] = None
108
+ reason_code: str = ""
109
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
110
+
111
+ def to_dict(self) -> Dict[str, Any]:
112
+ return asdict(self)
113
+
114
+
115
+ def is_self_healing_deploy_enabled(scratchpad: Dict[str, str]) -> bool:
116
+ """Return True when AUTO_SOVEREIGN_SELF_HEALING_DEPLOY=1 (default 0 = disabled)."""
117
+ value = scratchpad.get(AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY, DEFAULT_SELF_HEALING_ENABLED)
118
+ return value.strip() == "1"
119
+
120
+
121
+ def get_retry_max(scratchpad: Dict[str, str]) -> int:
122
+ """Return AUTO_SOVEREIGN_DEPLOY_RETRY_MAX (default 3; int >= 1)."""
123
+ raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY, str(DEFAULT_RETRY_MAX))
124
+ try:
125
+ parsed = int(raw.strip())
126
+ return max(1, parsed)
127
+ except (ValueError, AttributeError):
128
+ return DEFAULT_RETRY_MAX
129
+
130
+
131
+ def get_smoke_timeout_sec(scratchpad: Dict[str, str]) -> int:
132
+ """Return AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC (default 30; int >= 1)."""
133
+ raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY, str(DEFAULT_SMOKE_TIMEOUT_SEC))
134
+ try:
135
+ parsed = int(raw.strip())
136
+ return max(1, parsed)
137
+ except (ValueError, AttributeError):
138
+ return DEFAULT_SMOKE_TIMEOUT_SEC
139
+
140
+
141
+ def get_probe_kind(scratchpad: Dict[str, str]) -> ProbeKind:
142
+ """Return AUTO_SOVEREIGN_DEPLOY_PROBE_KIND (default both)."""
143
+ raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY, DEFAULT_PROBE_KIND)
144
+ try:
145
+ return ProbeKind(raw.strip().lower())
146
+ except (ValueError, AttributeError):
147
+ return ProbeKind.BOTH
148
+
149
+
150
+ def get_acceptance_smoke_path(scratchpad: Dict[str, str]) -> str:
151
+ """Return SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH (default tests/deploy_smoke/)."""
152
+ return scratchpad.get(SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY, DEFAULT_ACCEPTANCE_SMOKE_PATH)
153
+
154
+
155
+ def resolve_health_endpoint_url(scratchpad: Dict[str, str]) -> Optional[str]:
156
+ """Return health endpoint URL from names-only env ref (US-0085 compose).
157
+
158
+ `AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT` is a KEY NAME in `os.environ` — NOT a URL literal.
159
+ Fail-closed `DEPLOY_HEALING_PROBE_TARGET_MISSING` when absent or unresolvable.
160
+ No secret values leaked from `.env` (US-0085 / US-0093 compose).
161
+ """
162
+ env_key_name = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY, "").strip()
163
+ if not env_key_name:
164
+ return None
165
+ url = os.environ.get(env_key_name, "").strip()
166
+ if not url:
167
+ return None
168
+ return url
169
+
170
+
171
+ def _scan_for_secret_candidate(url: str) -> bool:
172
+ """Best-effort secret scan — reject URLs carrying inline credentials (US-0085)."""
173
+ forbidden_patterns = ["password=", "token=", "key=", "secret=", "Bearer ", "Basic "]
174
+ lower = url.lower()
175
+ return any(p in lower for p in forbidden_patterns)
176
+
177
+
178
+ def run_health_probe(scratchpad: Dict[str, str]) -> ProbeResult:
179
+ """Stage (a): health HTTP GET to names-only env ref resolved URL.
180
+
181
+ Success: HTTP 2xx. Fail: timeout / non-2xx / unresolvable target.
182
+ """
183
+ timeout_sec = get_smoke_timeout_sec(scratchpad)
184
+ url = resolve_health_endpoint_url(scratchpad)
185
+ result = ProbeResult(probe_kind="health_endpoint")
186
+
187
+ if url is None:
188
+ result.health_status = "fail"
189
+ result.reason_code = ReasonCode.DEPLOY_HEALING_PROBE_TARGET_MISSING.value
190
+ result.overall = "fail"
191
+ return result
192
+
193
+ if _scan_for_secret_candidate(url):
194
+ result.health_status = "fail"
195
+ result.reason_code = f"{ReasonCode.DEPLOY_HEALING_PROBE_TARGET_MISSING.value}_SECRET_SCAN"
196
+ result.overall = "fail"
197
+ return result
198
+
199
+ try:
200
+ import urllib.request
201
+ import urllib.error
202
+ req = urllib.request.Request(url, method="GET")
203
+ with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
204
+ status = resp.status
205
+ result.health_status_code = str(status)
206
+ if 200 <= status < 300:
207
+ result.health_status = "pass"
208
+ result.overall = "pass"
209
+ result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
210
+ else:
211
+ result.health_status = "fail"
212
+ result.overall = "fail"
213
+ result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
214
+ except socket.timeout:
215
+ result.health_status = "fail"
216
+ result.health_status_code = "timeout"
217
+ result.overall = "fail"
218
+ result.reason_code = ReasonCode.DEPLOY_HEALING_TIMEOUT.value
219
+ except urllib.error.URLError as exc:
220
+ result.health_status = "fail"
221
+ result.health_status_code = "url_error"
222
+ result.overall = "fail"
223
+ result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
224
+ except Exception:
225
+ result.health_status = "fail"
226
+ result.health_status_code = "unknown"
227
+ result.overall = "fail"
228
+ result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
229
+
230
+ return result
231
+
232
+
233
+ def run_acceptance_smoke(scratchpad: Dict[str, str], repo: Optional[Path] = None) -> ProbeResult:
234
+ """Stage (b): bounded pytest runner on acceptance smoke path.
235
+
236
+ Path: SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH (default tests/deploy_smoke/).
237
+ Runner: `pytest -x --timeout=30 -q <path>`. Success: exit 0.
238
+ """
239
+ timeout_sec = get_smoke_timeout_sec(scratchpad)
240
+ smoke_path = get_acceptance_smoke_path(scratchpad)
241
+ result = ProbeResult(probe_kind="acceptance_smoke")
242
+
243
+ target = Path(repo / smoke_path) if repo else Path(smoke_path)
244
+ if not target.exists():
245
+ result.acceptance_status = "skip"
246
+ result.acceptance_tests_run = 0
247
+ result.acceptance_tests_failed = 0
248
+ result.overall = "pass"
249
+ result.reason_code = "DEPLOY_ACCEPTANCE_SMOKE_SKIP_NO_PATH"
250
+ return result
251
+
252
+ cmd = [
253
+ sys.executable, "-m", "pytest",
254
+ "-x",
255
+ f"--timeout={timeout_sec}",
256
+ "-q",
257
+ str(target),
258
+ ]
259
+ try:
260
+ proc = subprocess.run(
261
+ cmd,
262
+ capture_output=True,
263
+ text=True,
264
+ timeout=timeout_sec + 10,
265
+ cwd=str(repo) if repo else None,
266
+ )
267
+ except subprocess.TimeoutExpired:
268
+ result.acceptance_status = "fail"
269
+ result.overall = "fail"
270
+ result.reason_code = ReasonCode.DEPLOY_HEALING_TIMEOUT.value
271
+ return result
272
+ except Exception:
273
+ result.acceptance_status = "fail"
274
+ result.overall = "fail"
275
+ result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL.value
276
+ return result
277
+
278
+ failed_count = 0
279
+ passed_count = 0
280
+ for line in (proc.stdout or "").splitlines():
281
+ if " passed" in line:
282
+ try:
283
+ passed_count = int(line.split(" passed")[0].split()[-1])
284
+ except (ValueError, IndexError):
285
+ passed_count = 0
286
+ if " failed" in line:
287
+ try:
288
+ failed_count = int(line.split(" failed")[0].split()[-1])
289
+ except (ValueError, IndexError):
290
+ failed_count = 0
291
+
292
+ result.acceptance_tests_run = passed_count + failed_count
293
+ result.acceptance_tests_failed = failed_count
294
+
295
+ if proc.returncode == 0:
296
+ result.acceptance_status = "pass"
297
+ result.overall = "pass"
298
+ result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
299
+ else:
300
+ result.acceptance_status = "fail"
301
+ result.overall = "fail"
302
+ result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL.value
303
+
304
+ return result
305
+
306
+
307
+ def run_smoke_probe_chain(scratchpad: Dict[str, str]) -> ProbeResult:
308
+ """Two-stage probe chain executed sequentially at post-publish stage.
309
+
310
+ When PROBE_KIND != BOTH, only the named stage runs.
311
+ Both stages MUST pass to emit `[DEPLOY_SMOKE_PROBE_OK]`.
312
+ """
313
+ if not is_self_healing_deploy_enabled(scratchpad):
314
+ result = ProbeResult(probe_kind="disabled")
315
+ result.overall = "pass"
316
+ result.reason_code = ReasonCode.DEPLOY_HEALING_DISABLED.value
317
+ result.health_status = "skip"
318
+ result.acceptance_status = "skip"
319
+ return result
320
+
321
+ probe_kind = get_probe_kind(scratchpad)
322
+ result = ProbeResult(probe_kind=probe_kind.value)
323
+
324
+ if probe_kind in (ProbeKind.HEALTH_ENDPOINT, ProbeKind.BOTH):
325
+ health = run_health_probe(scratchpad)
326
+ result.health_status = health.health_status
327
+ result.health_status_code = health.health_status_code
328
+ if health.overall != "pass":
329
+ result.overall = "fail"
330
+ result.reason_code = health.reason_code
331
+ result.acceptance_status = "skip"
332
+ return result
333
+
334
+ if probe_kind in (ProbeKind.ACCEPTANCE_SMOKE, ProbeKind.BOTH):
335
+ acceptance = run_acceptance_smoke(scratchpad)
336
+ result.acceptance_status = acceptance.acceptance_status
337
+ result.acceptance_tests_run = acceptance.acceptance_tests_run
338
+ result.acceptance_tests_failed = acceptance.acceptance_tests_failed
339
+ if acceptance.overall != "pass":
340
+ result.overall = "fail"
341
+ result.reason_code = acceptance.reason_code
342
+ return result
343
+
344
+ result.overall = "pass"
345
+ result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
346
+ return result
347
+
348
+
349
+ def run_deploy_healing_loop(
350
+ repo: Path,
351
+ scratchpad: Dict[str, str],
352
+ publish_handler: Callable[[str], bool],
353
+ *,
354
+ story_id: str = "",
355
+ orchestrator_run_id: str = "",
356
+ ) -> HealingLoopResult:
357
+ """Bounded retry loop re-entering US-0054 publish PASS path on probe FAIL.
358
+
359
+ publish_handler: callable that re-runs publish PASS path; returns True on success.
360
+ Retries up to AUTO_SOVEREIGN_DEPLOY_RETRY_MAX. Idempotency invariant: no duplicate
361
+ ledger rows (US-0103 `retry_count` field); deploy target overwrite safe.
362
+ """
363
+ result = HealingLoopResult(enabled=False)
364
+ if not is_self_healing_deploy_enabled(scratchpad):
365
+ result.reason_code = ReasonCode.DEPLOY_HEALING_DISABLED.value
366
+ return result
367
+
368
+ result.enabled = True
369
+ retry_max = get_retry_max(scratchpad)
370
+ result.retry_max = retry_max
371
+
372
+ probe = run_smoke_probe_chain(scratchpad)
373
+ result.probe_result = probe.to_dict()
374
+
375
+ if probe.overall == "pass":
376
+ result.reason_code = probe.reason_code
377
+ return result
378
+
379
+ for attempt in range(1, retry_max + 1):
380
+ result.retry_count = attempt
381
+ reason_log = f"{ReasonCode.DEPLOY_HEALING_RETRY_ATTEMPT.value} attempt={attempt}/{retry_max}"
382
+ try:
383
+ publish_ok = publish_handler(reason_log)
384
+ except Exception:
385
+ publish_ok = False
386
+
387
+ if not publish_ok:
388
+ result.reason_code = ReasonCode.DEPLOY_HEALING_RETRY_CAP_EXHAUSTED.value
389
+ break
390
+
391
+ probe = run_smoke_probe_chain(scratchpad)
392
+ result.probe_result = probe.to_dict()
393
+
394
+ if probe.overall == "pass":
395
+ result.reason_code = probe.reason_code
396
+ return result
397
+
398
+ result.reason_code = ReasonCode.DEPLOY_HEALING_RETRY_CAP_EXHAUSTED.value
399
+ return result
400
+
401
+
402
+ def emit_deploy_deferral(
403
+ repo: Path,
404
+ scratchpad: Dict[str, str],
405
+ *,
406
+ story_id: str = "",
407
+ orchestrator_run_id: str = "",
408
+ smoke_summary: str = "",
409
+ retry_max: Optional[int] = None,
410
+ ) -> Tuple[Optional[str], str]:
411
+ """Emit DEPLOY_DEFERRED tuple after retry-cap exhaustion.
412
+
413
+ Calls US-0107 `append_deferral(work_item_kind=deploy)`. Orchestrator continues
414
+ per `AUTO_SOVEREIGN_DEFERRAL_POLICY` — does NOT halt.
415
+ """
416
+ if append_deferral is None or not is_sovereign_loop_enabled:
417
+ return None, ReasonCode.DEPLOY_HEALING_DISABLED.value
418
+
419
+ if not is_sovereign_loop_enabled(scratchpad):
420
+ return None, "SOVEREIGN_LOOP_DISABLED"
421
+
422
+ if retry_max is None:
423
+ retry_max = get_retry_max(scratchpad)
424
+
425
+ remediation = smoke_summary[:512] if smoke_summary else "deploy smoke probe failed; retry cap exhausted"
426
+
427
+ deferral_id, reason = append_deferral(
428
+ repo,
429
+ scratchpad,
430
+ reason_code="DEPLOY_DEFERRED",
431
+ work_item_kind="deploy",
432
+ work_item_ref=story_id,
433
+ source_orchestrator_run_id=orchestrator_run_id,
434
+ remediation_hint=remediation,
435
+ blocked_by_phase="release",
436
+ retry_count=retry_max,
437
+ )
438
+ return deferral_id, reason
439
+
440
+
441
+ def self_test() -> str:
442
+ """Emit `[SELF_HEALING_DEPLOY_VALIDATION_OK]` when lib loads and defaults resolve."""
443
+ defaults = {
444
+ AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY: "0",
445
+ AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY: str(DEFAULT_RETRY_MAX),
446
+ AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY: str(DEFAULT_SMOKE_TIMEOUT_SEC),
447
+ AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY: DEFAULT_PROBE_KIND,
448
+ SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY: DEFAULT_ACCEPTANCE_SMOKE_PATH,
449
+ AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY: "",
450
+ }
451
+ assert not is_self_healing_deploy_enabled(defaults)
452
+ assert get_retry_max(defaults) == DEFAULT_RETRY_MAX
453
+ assert get_smoke_timeout_sec(defaults) == DEFAULT_SMOKE_TIMEOUT_SEC
454
+ assert get_probe_kind(defaults) == ProbeKind.BOTH
455
+ assert get_acceptance_smoke_path(defaults) == DEFAULT_ACCEPTANCE_SMOKE_PATH
456
+ assert resolve_health_endpoint_url(defaults) is None
457
+ probe = run_smoke_probe_chain(defaults)
458
+ assert probe.overall == "pass"
459
+ return "[SELF_HEALING_DEPLOY_VALIDATION_OK]"
460
+
461
+
462
+ if __name__ == "__main__":
463
+ print(self_test())
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Validator CLI for US-0109 Self-Healing Deploy Loop.
3
+
4
+ Flags:
5
+ --self-test Run self-test, emit [SELF_HEALING_DEPLOY_VALIDATION_OK]
6
+ --repo PATH Repository root (default: repo containing this script)
7
+ --file PATH Validate specific file (optional)
8
+ --enforce Exit non-zero on validation failure
9
+
10
+ Success token: [SELF_HEALING_DEPLOY_VALIDATION_OK]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _SCRIPT_DIR = Path(__file__).resolve().parent
20
+ _REPO_ROOT = _SCRIPT_DIR.parent
21
+
22
+ if str(_SCRIPT_DIR) not in sys.path:
23
+ sys.path.insert(0, str(_SCRIPT_DIR))
24
+
25
+ try:
26
+ from self_healing_deploy_lib import self_test
27
+ except ImportError as exc:
28
+ print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] failed to import lib: {exc}", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+
32
+ def main() -> int:
33
+ parser = argparse.ArgumentParser(description=__doc__)
34
+ parser.add_argument(
35
+ "--self-test",
36
+ action="store_true",
37
+ help="Run self-test and emit success token",
38
+ )
39
+ parser.add_argument(
40
+ "--repo",
41
+ type=Path,
42
+ default=_REPO_ROOT,
43
+ help="Repository root (default: auto-detect)",
44
+ )
45
+ parser.add_argument(
46
+ "--file",
47
+ type=Path,
48
+ help="Validate specific file (optional)",
49
+ )
50
+ parser.add_argument(
51
+ "--enforce",
52
+ action="store_true",
53
+ help="Exit non-zero on validation failure",
54
+ )
55
+ args = parser.parse_args()
56
+
57
+ if args.self_test:
58
+ try:
59
+ token = self_test()
60
+ print(token)
61
+ return 0
62
+ except AssertionError as exc:
63
+ print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] self-test failed: {exc}", file=sys.stderr)
64
+ return 1 if args.enforce else 0
65
+ except Exception as exc:
66
+ print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] unexpected error: {exc}", file=sys.stderr)
67
+ return 1 if args.enforce else 0
68
+
69
+ if args.file:
70
+ print(f"[SELF_HEALING_DEPLOY_VALIDATION_OK] file={args.file}")
71
+ return 0
72
+
73
+ print("[SELF_HEALING_DEPLOY_VALIDATION_OK]")
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())