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,843 @@
1
+ """
2
+ Release trigger adapters — dispatch release flow by trigger source (US-0111 / DEC-0111).
3
+
4
+ Four adapters:
5
+ - github: GitHub webhook release event → resolve tag + find previous via API
6
+ - npm: npm publish event → read package version + query registry
7
+ - git_tag: Git tag push → parse GITHUB_REF or local git describe
8
+ - manual: Legacy /release command → byte-identical to pre-US-0111
9
+
10
+ All adapters produce TriggerContext(version, previous_version, source, metadata).
11
+ Downstream reuse release_changelog_lib.compare_versions() and promote_unreleased()
12
+ without modification (compose — US-0100 read-only).
13
+
14
+ Reason codes (DEC-0111 §7):
15
+ RELEASE_TRIGGER_ADAPTER_FAILED
16
+ RELEASE_TRIGGER_TAG_MISSING
17
+ RELEASE_TRIGGER_PREVIOUS_MISSING
18
+ RELEASE_TRIGGER_PACKAGE_JSON_MISSING
19
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED
20
+ RELEASE_TRIGGER_NOTES_WRITE_FAILED
21
+ RELEASE_TRIGGER_EVENT_EMIT_FAILED
22
+ RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED
23
+ RELEASE_TRIGGER_SOURCE_INVALID
24
+
25
+ Default source: RELEASE_TRIGGER_SOURCE=manual (zero behavior change vs pre-US-0111).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import abc
31
+ import json
32
+ import os
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime, timezone
40
+ from pathlib import Path
41
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
42
+
43
+ # Semantic version pattern
44
+ SEMVER_RE = re.compile(
45
+ r"^v?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$"
46
+ )
47
+
48
+ # --- Scratchpad key contracts (DEC-0111 §1) ----------------------------------
49
+
50
+ RELEASE_TRIGGER_SOURCE_KEY = "RELEASE_TRIGGER_SOURCE"
51
+ RELEASE_TRIGGER_SOURCE_VALUES = frozenset({"manual", "github", "npm", "git_tag", "auto"})
52
+ RELEASE_TRIGGER_SOURCE_DEFAULT = "manual"
53
+
54
+ RELEASE_TRIGGER_TIMEOUT_SEC_KEY = "RELEASE_TRIGGER_TIMEOUT_SEC"
55
+ RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT = 10
56
+
57
+ RELEASE_TRIGGER_FALLBACK_TO_LOCAL_KEY = "RELEASE_TRIGGER_FALLBACK_TO_LOCAL"
58
+ RELEASE_TRIGGER_FALLBACK_TO_LOCAL_DEFAULT = "0"
59
+
60
+ # --- Reason codes (DEC-0111 §7, 9 codes) -------------------------------------
61
+
62
+ RELEASE_TRIGGER_ADAPTER_FAILED = "RELEASE_TRIGGER_ADAPTER_FAILED"
63
+ RELEASE_TRIGGER_TAG_MISSING = "RELEASE_TRIGGER_TAG_MISSING"
64
+ RELEASE_TRIGGER_PREVIOUS_MISSING = "RELEASE_TRIGGER_PREVIOUS_MISSING"
65
+ RELEASE_TRIGGER_PACKAGE_JSON_MISSING = "RELEASE_TRIGGER_PACKAGE_JSON_MISSING"
66
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED = "RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED"
67
+ RELEASE_TRIGGER_NOTES_WRITE_FAILED = "RELEASE_TRIGGER_NOTES_WRITE_FAILED"
68
+ RELEASE_TRIGGER_EVENT_EMIT_FAILED = "RELEASE_TRIGGER_EVENT_EMIT_FAILED"
69
+ RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED = "RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED"
70
+ RELEASE_TRIGGER_SOURCE_INVALID = "RELEASE_TRIGGER_SOURCE_INVALID"
71
+
72
+ FAIL_CODES: Tuple[str, ...] = (
73
+ RELEASE_TRIGGER_ADAPTER_FAILED,
74
+ RELEASE_TRIGGER_TAG_MISSING,
75
+ RELEASE_TRIGGER_PREVIOUS_MISSING,
76
+ RELEASE_TRIGGER_PACKAGE_JSON_MISSING,
77
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
78
+ RELEASE_TRIGGER_NOTES_WRITE_FAILED,
79
+ RELEASE_TRIGGER_EVENT_EMIT_FAILED,
80
+ RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
81
+ RELEASE_TRIGGER_SOURCE_INVALID,
82
+ )
83
+
84
+ FAIL_CODES_COUNT = 9
85
+
86
+ # --- TriggerContext dataclass ------------------------------------------------
87
+
88
+
89
+ @dataclass
90
+ class TriggerContext:
91
+ version: str
92
+ previous_version: Optional[str]
93
+ source: str # manual | github | npm | git_tag
94
+ metadata: Dict[str, Any] = field(default_factory=dict)
95
+
96
+
97
+ # --- Abstract base class ------------------------------------------------------
98
+
99
+
100
+ class ReleaseAdapter(abc.ABC):
101
+ """Abstract base class for release trigger adapters."""
102
+
103
+ source_name: str = "unknown"
104
+
105
+ @abc.abstractmethod
106
+ def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
107
+ """Return TriggerContext when this adapter matches, None otherwise."""
108
+
109
+ @abc.abstractmethod
110
+ def get_version_info(self) -> TriggerContext:
111
+ """Resolve version + previous; fail-closed on missing data."""
112
+
113
+
114
+ # --- GitHub webhook adapter ---------------------------------------------------
115
+
116
+
117
+ def _strip_v(tag: str) -> str:
118
+ return tag.lstrip("v").lstrip("V")
119
+
120
+
121
+ def _is_semver(tag: str) -> bool:
122
+ return bool(SEMVER_RE.match(tag.strip()))
123
+
124
+
125
+ def _semver_sort_key(tag: str) -> Tuple[int, ...]:
126
+ base = _strip_v(tag).split("-", 1)[0]
127
+ parts = base.split(".")
128
+ out: List[int] = []
129
+ for p in parts:
130
+ try:
131
+ out.append(int(p))
132
+ except ValueError:
133
+ out.append(0)
134
+ while len(out) < 3:
135
+ out.append(0)
136
+ return tuple(out)
137
+
138
+
139
+ class GithubReleaseAdapter(ReleaseAdapter):
140
+ """GitHub webhook release event adapter (AC-2).
141
+
142
+ Parse release.tag_name from webhook payload; query GitHub API to find
143
+ previous tag (sorted by created_at desc, skip current). Fallback:
144
+ git ls-remote --tags origin filtered for semver.
145
+ """
146
+
147
+ source_name = "github"
148
+
149
+ def __init__(
150
+ self,
151
+ env_vars: Optional[Dict[str, str]] = None,
152
+ payload: Optional[Dict[str, Any]] = None,
153
+ timeout_sec: int = RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT,
154
+ ) -> None:
155
+ self.env = env_vars or dict(os.environ)
156
+ self.payload = payload or {}
157
+ self.timeout_sec = timeout_sec
158
+
159
+ def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
160
+ env = env_vars or self.env
161
+ if env.get("GITHUB_EVENT_NAME") == "release" and env.get("GITHUB_EVENT_PATH"):
162
+ try:
163
+ with open(env["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as f:
164
+ payload = json.load(f)
165
+ tag = payload.get("release", {}).get("tag_name", "")
166
+ if tag and _is_semver(tag):
167
+ return self._resolve(tag, env)
168
+ except (OSError, json.JSONDecodeError):
169
+ return None
170
+ return None
171
+
172
+ def get_version_info(self) -> TriggerContext:
173
+ tag = self.payload.get("release", {}).get("tag_name", "")
174
+ if not tag or not _is_semver(tag):
175
+ raise ReleaseTriggerError(
176
+ RELEASE_TRIGGER_TAG_MISSING, "GitHub payload missing release.tag_name"
177
+ )
178
+ return self._resolve(tag, self.env)
179
+
180
+ def _resolve(self, tag: str, env: Dict[str, str]) -> TriggerContext:
181
+ version = _strip_v(tag)
182
+ previous = self._find_previous(env, version)
183
+ return TriggerContext(
184
+ version=version,
185
+ previous_version=previous,
186
+ source=self.source_name,
187
+ metadata={"tag_name": tag, "token_env_ref": "GITHUB_TOKEN"},
188
+ )
189
+
190
+ def _find_previous(self, env: Dict[str, str], current_version: str) -> Optional[str]:
191
+ token = env.get("GITHUB_TOKEN", "")
192
+ repo_slug = env.get("GITHUB_REPOSITORY", "")
193
+ if token and repo_slug:
194
+ tags = self._api_tags(token, repo_slug, env)
195
+ if tags is not None:
196
+ for t in tags:
197
+ if _is_semver(t) and _strip_v(t) != current_version:
198
+ return _strip_v(t)
199
+ return None
200
+ tags = self._ls_remote_tags(env)
201
+ for t in tags:
202
+ if _is_semver(t) and _strip_v(t) != current_version:
203
+ return _strip_v(t)
204
+ return None
205
+
206
+ def _api_tags(
207
+ self, token: str, repo_slug: str, env: Dict[str, str]
208
+ ) -> Optional[List[str]]:
209
+ import urllib.request
210
+ import urllib.error
211
+
212
+ url = f"https://api.github.com/repos/{repo_slug}/releases?per_page=100"
213
+ req = urllib.request.Request(url)
214
+ req.add_header("Authorization", f"token {token}")
215
+ req.add_header("Accept", "application/vnd.github.v3+json")
216
+ try:
217
+ with urllib.request.urlopen(req, timeout=self.timeout_sec) as resp:
218
+ data = json.loads(resp.read().decode("utf-8"))
219
+ releases = [(r["tag_name"], r.get("created_at", "")) for r in data if "tag_name" in r]
220
+ releases.sort(key=lambda x: x[1], reverse=True)
221
+ return [r[0] for r in releases]
222
+ except Exception:
223
+ return None
224
+
225
+ def _ls_remote_tags(self, env: Dict[str, str]) -> List[str]:
226
+ try:
227
+ result = subprocess.run(
228
+ ["git", "ls-remote", "--tags", "origin"],
229
+ capture_output=True,
230
+ text=True,
231
+ timeout=self.timeout_sec,
232
+ cwd=env.get("GIT_WORK_DIR", None),
233
+ )
234
+ if result.returncode != 0:
235
+ return []
236
+ tags: List[str] = []
237
+ for line in result.stdout.splitlines():
238
+ parts = line.split("\t")
239
+ if len(parts) >= 2 and parts[1].startswith("refs/tags/"):
240
+ tag = parts[1][len("refs/tags/") :]
241
+ if not tag.endswith("^{}") and _is_semver(tag):
242
+ tags.append(tag)
243
+ tags.sort(key=_semver_sort_key, reverse=True)
244
+ return tags
245
+ except Exception:
246
+ return []
247
+
248
+
249
+ # --- npm publish adapter ------------------------------------------------------
250
+
251
+
252
+ class NpmPublishAdapter(ReleaseAdapter):
253
+ """npm publish event adapter (AC-3).
254
+
255
+ Read npm_package_version env var; query npm registry for previous version.
256
+ Offline fallback: package-lock.json when RELEASE_TRIGGER_FALLBACK_TO_LOCAL=1.
257
+ """
258
+
259
+ source_name = "npm"
260
+
261
+ def __init__(
262
+ self,
263
+ env_vars: Optional[Dict[str, str]] = None,
264
+ timeout_sec: int = RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT,
265
+ fallback_to_local: bool = False,
266
+ repo_root: Optional[str] = None,
267
+ ) -> None:
268
+ self.env = env_vars or dict(os.environ)
269
+ self.timeout_sec = timeout_sec
270
+ self.fallback_to_local = fallback_to_local
271
+ self.repo_root = repo_root or "."
272
+
273
+ def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
274
+ env = env_vars or self.env
275
+ pkg_version = env.get("npm_package_version", "")
276
+ if pkg_version and _is_semver(pkg_version):
277
+ return self._resolve(pkg_version, env)
278
+ return None
279
+
280
+ def get_version_info(self) -> TriggerContext:
281
+ pkg_version = self.env.get("npm_package_version", "")
282
+ if not pkg_version or not _is_semver(pkg_version):
283
+ pkg_lock = self._read_package_lock()
284
+ if pkg_lock:
285
+ return TriggerContext(
286
+ version=pkg_lock,
287
+ previous_version=None,
288
+ source=self.source_name,
289
+ metadata={"fallback": "package-lock.json"},
290
+ )
291
+ raise ReleaseTriggerError(
292
+ RELEASE_TRIGGER_PACKAGE_JSON_MISSING,
293
+ "npm_package_version env var missing and package-lock.json unreadable",
294
+ )
295
+ return self._resolve(pkg_version, self.env)
296
+
297
+ def _resolve(self, version: str, env: Dict[str, str]) -> TriggerContext:
298
+ previous = self._find_previous(env, version)
299
+ return TriggerContext(
300
+ version=version,
301
+ previous_version=previous,
302
+ source=self.source_name,
303
+ metadata={"npm_package_version": version},
304
+ )
305
+
306
+ def _find_previous(self, env: Dict[str, str], current: str) -> Optional[str]:
307
+ versions = self._registry_versions(env)
308
+ if versions is not None:
309
+ sv_versions = [v for v in versions if _is_semver(v)]
310
+ sv_versions.sort(key=_semver_sort_key, reverse=True)
311
+ for v in sv_versions:
312
+ if v != current and v != _strip_v(current):
313
+ return v
314
+ return None
315
+ if self.fallback_to_local:
316
+ return self._read_package_lock()
317
+ return None
318
+
319
+ def _registry_versions(self, env: Dict[str, str]) -> Optional[List[str]]:
320
+ pkg_name = env.get("npm_package_name", "")
321
+ if not pkg_name:
322
+ return None
323
+ try:
324
+ result = subprocess.run(
325
+ ["npm", "view", pkg_name, "versions", "--json"],
326
+ capture_output=True,
327
+ text=True,
328
+ timeout=self.timeout_sec,
329
+ )
330
+ if result.returncode != 0:
331
+ return None
332
+ data = json.loads(result.stdout)
333
+ if isinstance(data, list):
334
+ return data
335
+ return None
336
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
337
+ return None
338
+
339
+ def _read_package_lock(self) -> Optional[str]:
340
+ lock_path = os.path.join(self.repo_root, "package-lock.json")
341
+ if not os.path.isfile(lock_path):
342
+ return None
343
+ try:
344
+ with open(lock_path, "r", encoding="utf-8") as f:
345
+ data = json.load(f)
346
+ version = data.get("version", "")
347
+ if version and _is_semver(version):
348
+ return version
349
+ except (OSError, json.JSONDecodeError):
350
+ pass
351
+ return None
352
+
353
+
354
+ # --- Git tag push adapter -----------------------------------------------------
355
+
356
+
357
+ class GitTagAdapter(ReleaseAdapter):
358
+ """Git tag push adapter (AC-4).
359
+
360
+ Parse GITHUB_REF (CI) or local git describe --tags --abbrev=0.
361
+ Compute previous_version via git for-each-ref --sort=-version:refname refs/tags.
362
+ """
363
+
364
+ source_name = "git_tag"
365
+
366
+ def __init__(
367
+ self,
368
+ env_vars: Optional[Dict[str, str]] = None,
369
+ repo_root: Optional[str] = None,
370
+ ) -> None:
371
+ self.env = env_vars or dict(os.environ)
372
+ self.repo_root = repo_root or "."
373
+
374
+ def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
375
+ env = env_vars or self.env
376
+ ref = env.get("GITHUB_REF", "")
377
+ if ref.startswith("refs/tags/"):
378
+ tag = ref[len("refs/tags/") :]
379
+ if _is_semver(tag):
380
+ return self._resolve(tag)
381
+ try:
382
+ tag = self._git_describe_tag()
383
+ if tag and _is_semver(tag):
384
+ return self._resolve(tag)
385
+ except Exception:
386
+ pass
387
+ return None
388
+
389
+ def get_version_info(self) -> TriggerContext:
390
+ ref = self.env.get("GITHUB_REF", "")
391
+ if ref.startswith("refs/tags/"):
392
+ tag = ref[len("refs/tags/") :]
393
+ if _is_semver(tag):
394
+ return self._resolve(tag)
395
+ raise ReleaseTriggerError(
396
+ RELEASE_TRIGGER_TAG_MISSING, f"GITHUB_REF tag is not semver: {ref!r}"
397
+ )
398
+ tag = self._git_describe_tag()
399
+ if tag and _is_semver(tag):
400
+ return self._resolve(tag)
401
+ raise ReleaseTriggerError(
402
+ RELEASE_TRIGGER_TAG_MISSING, "Cannot resolve current tag from GITHUB_REF or git describe"
403
+ )
404
+
405
+ def _resolve(self, tag: str) -> TriggerContext:
406
+ version = _strip_v(tag)
407
+ previous = self._find_previous()
408
+ return TriggerContext(
409
+ version=version,
410
+ previous_version=previous,
411
+ source=self.source_name,
412
+ metadata={"tag_name": tag},
413
+ )
414
+
415
+ def _git_describe_tag(self) -> Optional[str]:
416
+ try:
417
+ result = subprocess.run(
418
+ ["git", "describe", "--tags", "--abbrev=0"],
419
+ capture_output=True,
420
+ text=True,
421
+ cwd=self.repo_root,
422
+ )
423
+ if result.returncode == 0:
424
+ return result.stdout.strip()
425
+ except Exception:
426
+ pass
427
+ return None
428
+
429
+ def _find_previous(self) -> Optional[str]:
430
+ try:
431
+ result = subprocess.run(
432
+ ["git", "for-each-ref", "--sort=-version:refname", "refs/tags"],
433
+ capture_output=True,
434
+ text=True,
435
+ cwd=self.repo_root,
436
+ )
437
+ if result.returncode != 0:
438
+ return None
439
+ current = None
440
+ ref = self.env.get("GITHUB_REF", "")
441
+ if ref.startswith("refs/tags/"):
442
+ current = _strip_v(ref[len("refs/tags/") :])
443
+ for line in result.stdout.splitlines():
444
+ parts = line.split()
445
+ if len(parts) >= 3:
446
+ refname = parts[2]
447
+ if refname.startswith("refs/tags/"):
448
+ tag = refname[len("refs/tags/") :]
449
+ if _is_semver(tag):
450
+ v = _strip_v(tag)
451
+ if current is not None and v == current:
452
+ continue
453
+ return v
454
+ except Exception:
455
+ pass
456
+ return None
457
+
458
+
459
+ # --- Manual adapter (backward compatibility) ----------------------------------
460
+
461
+
462
+ class ManualReleaseAdapter(ReleaseAdapter):
463
+ """Manual /release adapter (AC-5).
464
+
465
+ Byte-identical to pre-US-0111 /release behavior.
466
+ source=manual, version=current, previous_version=None.
467
+ """
468
+
469
+ source_name = "manual"
470
+
471
+ def __init__(
472
+ self,
473
+ current_version: Optional[str] = None,
474
+ env_vars: Optional[Dict[str, str]] = None,
475
+ repo_root: Optional[str] = None,
476
+ ) -> None:
477
+ self.current_version = current_version
478
+ self.env = env_vars or dict(os.environ)
479
+ self.repo_root = repo_root or "."
480
+
481
+ def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
482
+ return TriggerContext(
483
+ version=self.current_version or "0.0.0",
484
+ previous_version=None,
485
+ source=self.source_name,
486
+ metadata={"manual": True},
487
+ )
488
+
489
+ def get_version_info(self) -> TriggerContext:
490
+ return TriggerContext(
491
+ version=self.current_version or "0.0.0",
492
+ previous_version=None,
493
+ source=self.source_name,
494
+ metadata={"manual": True},
495
+ )
496
+
497
+
498
+ # --- Adapter registry ---------------------------------------------------------
499
+
500
+ _ADAPTER_MAP: Dict[str, type] = {
501
+ "github": GithubReleaseAdapter,
502
+ "npm": NpmPublishAdapter,
503
+ "git_tag": GitTagAdapter,
504
+ "manual": ManualReleaseAdapter,
505
+ }
506
+
507
+ # Auto-detection priority order
508
+ _AUTO_PRIORITY: Tuple[str, ...] = ("github", "npm", "git_tag", "manual")
509
+
510
+
511
+ class ReleaseTriggerError(Exception):
512
+ def __init__(self, code: str, message: str = "") -> None:
513
+ self.code = code
514
+ super().__init__(message or code)
515
+
516
+
517
+ def dispatch_to_adapter(
518
+ source: str,
519
+ env_vars: Optional[Dict[str, str]] = None,
520
+ *,
521
+ current_version: Optional[str] = None,
522
+ repo_root: Optional[str] = None,
523
+ payload: Optional[Dict[str, Any]] = None,
524
+ ) -> TriggerContext:
525
+ """
526
+ Resolve source and dispatch to the matching adapter. Returns TriggerContext.
527
+
528
+ source values: manual, github, npm, git_tag, auto
529
+ Invalid source → RELEASE_TRIGGER_SOURCE_INVALID (fail-closed).
530
+ """
531
+ if source not in RELEASE_TRIGGER_SOURCE_VALUES:
532
+ raise ReleaseTriggerError(
533
+ RELEASE_TRIGGER_SOURCE_INVALID,
534
+ f"Unknown RELEASE_TRIGGER_SOURCE={source!r}; "
535
+ f"allowed: {sorted(RELEASE_TRIGGER_SOURCE_VALUES)}",
536
+ )
537
+
538
+ env = env_vars or dict(os.environ)
539
+
540
+ if source == "auto":
541
+ return _auto_dispatch(env, current_version=current_version, repo_root=repo_root, payload=payload)
542
+
543
+ adapter = _instantiate(source, env, current_version, repo_root, payload)
544
+ return adapter.get_version_info()
545
+
546
+
547
+ def _auto_dispatch(
548
+ env: Dict[str, str],
549
+ *,
550
+ current_version: Optional[str] = None,
551
+ repo_root: Optional[str] = None,
552
+ payload: Optional[Dict[str, Any]] = None,
553
+ ) -> TriggerContext:
554
+ for source_name in _AUTO_PRIORITY:
555
+ if source_name == "manual":
556
+ continue
557
+ adapter = _instantiate(source_name, env, current_version, repo_root, payload)
558
+ ctx = adapter.detect(env)
559
+ if ctx is not None:
560
+ return ctx
561
+ return ManualReleaseAdapter(
562
+ current_version=current_version,
563
+ env_vars=env,
564
+ repo_root=repo_root,
565
+ ).get_version_info()
566
+
567
+
568
+ def _instantiate(
569
+ source: str,
570
+ env: Dict[str, str],
571
+ current_version: Optional[str],
572
+ repo_root: Optional[str],
573
+ payload: Optional[Dict[str, Any]],
574
+ ) -> ReleaseAdapter:
575
+ cls = _ADAPTER_MAP[source]
576
+ if source == "github":
577
+ return cls(env_vars=env, payload=payload or {})
578
+ if source == "npm":
579
+ return cls(env_vars=env, repo_root=repo_root)
580
+ if source == "git_tag":
581
+ return cls(env_vars=env, repo_root=repo_root)
582
+ if source == "manual":
583
+ return cls(current_version=current_version, env_vars=env, repo_root=repo_root)
584
+ return cls()
585
+
586
+
587
+ # --- Version comparison integration (T-006 / AC-6) ---------------------------
588
+
589
+
590
+ def compare_versions_from_trigger(trigger: TriggerContext) -> Tuple[str, Optional[str]]:
591
+ """
592
+ Compute semver diff from TriggerContext.
593
+ Uses release_changelog_lib.normalize_semver for validation.
594
+ Fail-closed: RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED.
595
+ """
596
+ try:
597
+ from release_changelog_lib import normalize_semver, ReleaseChangelogError
598
+ except ImportError:
599
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
600
+ from release_changelog_lib import normalize_semver, ReleaseChangelogError
601
+
602
+ try:
603
+ norm_current = normalize_semver(trigger.version)
604
+ except ReleaseChangelogError as exc:
605
+ raise ReleaseTriggerError(
606
+ RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
607
+ f"Cannot normalize current version: {exc}",
608
+ )
609
+
610
+ norm_previous: Optional[str] = None
611
+ if trigger.previous_version:
612
+ try:
613
+ norm_previous = normalize_semver(trigger.previous_version)
614
+ except ReleaseChangelogError as exc:
615
+ raise ReleaseTriggerError(
616
+ RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
617
+ f"Cannot normalize previous version: {exc}",
618
+ )
619
+
620
+ return norm_current, norm_previous
621
+
622
+
623
+ # --- Atomic file write (T-007, T-008 / AC-7, AC-8) ---------------------------
624
+
625
+ _ATOMIC_RETRY_DELAY = 0.1
626
+ _ATOMIC_RETRY_COUNT = 2
627
+
628
+
629
+ def atomic_write_file(target_path: str, content: str) -> None:
630
+ """
631
+ Write content to target_path atomically via os.replace(temp, target).
632
+ Best-effort on Windows: catch PermissionError, retry 0.1s.
633
+ Fail-closed: RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED.
634
+ """
635
+ target_dir = os.path.dirname(target_path) or "."
636
+ os.makedirs(target_dir, exist_ok=True)
637
+
638
+ for attempt in range(_ATOMIC_RETRY_COUNT + 1):
639
+ try:
640
+ fd, tmp_path = tempfile.mkstemp(
641
+ dir=target_dir, suffix=".tmp", prefix=".atomic_"
642
+ )
643
+ try:
644
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
645
+ f.write(content)
646
+ os.replace(tmp_path, target_path)
647
+ return
648
+ except Exception:
649
+ try:
650
+ os.unlink(tmp_path)
651
+ except OSError:
652
+ pass
653
+ raise
654
+ except PermissionError:
655
+ if attempt < _ATOMIC_RETRY_COUNT:
656
+ time.sleep(_ATOMIC_RETRY_DELAY)
657
+ continue
658
+ raise ReleaseTriggerError(
659
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
660
+ f"Atomic write failed (PermissionError after retries): {target_path}",
661
+ )
662
+ except ReleaseTriggerError:
663
+ raise
664
+ except Exception as exc:
665
+ raise ReleaseTriggerError(
666
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
667
+ f"Atomic write failed: {target_path}: {exc}",
668
+ )
669
+
670
+
671
+ def promote_changelog_version(
672
+ semver: str,
673
+ sprint_ids: Sequence[str],
674
+ repo_root: str,
675
+ release_date: Optional[str] = None,
676
+ ) -> str:
677
+ """
678
+ Promote [Unreleased] → [X.Y.Z] atomically + generate per-version doc.
679
+ Reuses release_changelog_lib.promote_unreleased() without modification.
680
+ Fail-closed: RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED.
681
+ """
682
+ from release_changelog_lib import (
683
+ promote_unreleased,
684
+ ensure_changelog_stub,
685
+ changelog_path,
686
+ read_utf8,
687
+ )
688
+
689
+ try:
690
+ ensure_changelog_stub(repo_root)
691
+ changelog = changelog_path(repo_root)
692
+ old_text = read_utf8(changelog)
693
+
694
+ promote_unreleased(semver, sprint_ids, repo_root, release_date)
695
+
696
+ new_text = read_utf8(changelog)
697
+ if new_text != old_text:
698
+ atomic_write_file(changelog, new_text)
699
+
700
+ return changelog
701
+ except ReleaseTriggerError:
702
+ raise
703
+ except Exception as exc:
704
+ raise ReleaseTriggerError(
705
+ RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
706
+ f"CHANGELOG promotion failed: {exc}",
707
+ )
708
+
709
+
710
+ def write_per_version_notes(
711
+ semver: str,
712
+ sprint_ids: Sequence[str],
713
+ repo_root: str,
714
+ ) -> str:
715
+ """
716
+ Write handoffs/releases/vX.Y.Z-release-notes.md atomically.
717
+ Reuses release_changelog_lib.build_version_doc() read-only compose.
718
+ Fail-closed: RELEASE_TRIGGER_NOTES_WRITE_FAILED.
719
+ """
720
+ from release_changelog_lib import (
721
+ build_version_doc,
722
+ version_doc_path,
723
+ normalize_semver,
724
+ derive_work_items,
725
+ version_fingerprint,
726
+ ReleaseChangelogError,
727
+ )
728
+
729
+ try:
730
+ norm = normalize_semver(semver)
731
+ work_items = derive_work_items(sprint_ids, repo_root)
732
+ fp = version_fingerprint(norm, [w.item_id for w in work_items])
733
+ target_path = version_doc_path(repo_root, norm)
734
+
735
+ content_lines = [
736
+ f"# Release notes — {norm}",
737
+ "",
738
+ f"<!-- release_changelog_fingerprint: {fp} -->",
739
+ "",
740
+ "> Per-version GitHub `-F` SOT (US-0100). Sprint-scoped evidence in "
741
+ "`handoffs/releases/Sxxxx-release-notes.md`.",
742
+ "",
743
+ "## Work items",
744
+ "",
745
+ ]
746
+ for wi in work_items:
747
+ content_lines.append(f"- **{wi.item_id}** — {wi.summary}")
748
+ content_lines.extend(["", "## Sprint evidence", ""])
749
+ for sid in sorted(set(sprint_ids)):
750
+ content_lines.append(f"- [`{sid}`](handoffs/releases/{sid}-release-notes.md)")
751
+ content_lines.append("")
752
+ content = "\n".join(content_lines)
753
+
754
+ atomic_write_file(target_path, content)
755
+ return target_path
756
+ except ReleaseTriggerError:
757
+ raise
758
+ except Exception as exc:
759
+ raise ReleaseTriggerError(
760
+ RELEASE_TRIGGER_NOTES_WRITE_FAILED,
761
+ f"Per-version notes write failed for {semver}: {exc}",
762
+ )
763
+
764
+
765
+ # --- Ledger event emit (T-009 / AC-9) -----------------------------------------
766
+
767
+
768
+ def emit_version_derivation_event(
769
+ trigger: TriggerContext,
770
+ norm_version: str,
771
+ norm_previous: Optional[str],
772
+ repo_root: str,
773
+ scratchpad: Optional[Dict[str, str]] = None,
774
+ ) -> Dict[str, Any]:
775
+ """
776
+ Emit (semver, previous_semver, timestamp, derivation_decisions[]) event
777
+ to US-0103 ledger via append_entry(decision_type='version_derivation').
778
+ Also write handoffs/release_events/{iso-timestamp}-{semver}.json.
779
+ Ledger schema unchanged (consumer-only append compose).
780
+ Fail-closed: RELEASE_TRIGGER_EVENT_EMIT_FAILED.
781
+ """
782
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
783
+ iso_compact = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
784
+
785
+ derivation_decisions: List[str] = []
786
+ if trigger.source != "manual":
787
+ derivation_decisions.append(f"trigger_source={trigger.source}")
788
+ if norm_previous:
789
+ derivation_decisions.append(f"previous={norm_previous}")
790
+
791
+ event_payload = {
792
+ "semver": norm_version,
793
+ "previous_semver": norm_previous,
794
+ "timestamp_iso": ts,
795
+ "derivation_decisions": derivation_decisions,
796
+ "source": trigger.source,
797
+ "metadata": trigger.metadata,
798
+ }
799
+
800
+ event_dir = os.path.join(repo_root, "handoffs", "release_events")
801
+ os.makedirs(event_dir, exist_ok=True)
802
+ event_filename = f"{iso_compact}-{norm_version}.json"
803
+ event_path = os.path.join(event_dir, event_filename)
804
+
805
+ try:
806
+ atomic_write_file(event_path, json.dumps(event_payload, indent=2, sort_keys=True) + "\n")
807
+ except ReleaseTriggerError:
808
+ raise
809
+ except Exception as exc:
810
+ raise ReleaseTriggerError(
811
+ RELEASE_TRIGGER_EVENT_EMIT_FAILED,
812
+ f"Failed to write release event file: {exc}",
813
+ )
814
+
815
+ try:
816
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
817
+ import decision_ledger_lib as ledger
818
+
819
+ ledger_path = Path(repo_root) / "handoffs" / "sovereign_decisions" / "decisions.jsonl"
820
+ ledger_entry = {
821
+ "ts": ts,
822
+ "orchestrator_run_id": (scratchpad or {}).get("ORCHESTRATOR_RUN_ID", "manual"),
823
+ "phase_id": "release",
824
+ "role": "dev",
825
+ "decision_id": f"version-derivation-{iso_compact}-{norm_version}",
826
+ "decision_type": "version_derivation",
827
+ "from_artifact": f"trigger:{trigger.source}",
828
+ "to_artifact": event_path,
829
+ "rationale": json.dumps(event_payload, sort_keys=True),
830
+ "plan_fidelity": (scratchpad or {}).get("AUTO_PLAN_FIDELITY", "strict"),
831
+ "cross_model_reviewed": False,
832
+ "risk_tier": "low",
833
+ }
834
+ result = ledger.append_entry(ledger_path, ledger_entry, scratchpad=scratchpad)
835
+ except ReleaseTriggerError:
836
+ raise
837
+ except Exception as exc:
838
+ raise ReleaseTriggerError(
839
+ RELEASE_TRIGGER_EVENT_EMIT_FAILED,
840
+ f"Failed to append ledger entry: {exc}",
841
+ )
842
+
843
+ return {"event_path": event_path, "ledger_result": result, "payload": event_payload}