its-magic 0.1.2-9 → 0.1.3-0

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 (237) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +93 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +1036 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +312 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +523 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/CHANGELOG.md +11 -0
  65. package/template/README.md +1602 -0
  66. package/template/decisions/DEC-0001.md +11 -0
  67. package/template/decisions/DEC-0002.md +11 -0
  68. package/template/docs/developer/README.md +44 -0
  69. package/template/docs/engineering/architecture.md +9 -0
  70. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  71. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  72. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  73. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  74. package/template/docs/engineering/compatibility-report.md +20 -0
  75. package/template/docs/engineering/compatibility-signals.md +7 -0
  76. package/template/docs/engineering/component-scope-report.md +16 -0
  77. package/template/docs/engineering/component-scope.md +19 -0
  78. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  79. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  80. package/template/docs/engineering/decisions.md +16 -0
  81. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  82. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  83. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  84. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  85. package/template/docs/engineering/phase-context.md +16 -0
  86. package/template/docs/engineering/release-targets.json +123 -0
  87. package/template/docs/engineering/research.md +29 -0
  88. package/template/docs/engineering/runbook.md +2391 -0
  89. package/template/docs/engineering/runtime-connectivity.md +81 -0
  90. package/template/docs/engineering/security-review.md +33 -0
  91. package/template/docs/engineering/spec-pack/README.md +20 -0
  92. package/template/docs/engineering/state-archive/README.md +15 -0
  93. package/template/docs/engineering/state.md +20 -0
  94. package/template/docs/engineering/status-normalization-report.md +19 -0
  95. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  96. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  97. package/template/docs/product/acceptance.md +1 -0
  98. package/template/docs/product/backlog.md +5 -0
  99. package/template/docs/product/vision.md +11 -0
  100. package/template/docs/user-guides/README.md +40 -0
  101. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  102. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  103. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  104. package/template/handoffs/release_notes.md +51 -0
  105. package/template/handoffs/release_queue.md +47 -0
  106. package/template/handoffs/release_to_dev.md +31 -0
  107. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  108. package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
  109. package/template/handoffs/resume_brief.md +14 -0
  110. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  111. package/template/handoffs/token_cost_runs/README.md +26 -0
  112. package/template/its_magic/.its-magic-version +1 -0
  113. package/template/its_magic/README.md +9 -0
  114. package/template/scripts/auto_outer_driver.py +521 -0
  115. package/template/scripts/caveman_compress_input.py +903 -0
  116. package/template/scripts/check_downstream_ci_guard.py +67 -0
  117. package/template/scripts/check_intake_template_parity.py +312 -0
  118. package/template/scripts/check_token_cost_parity.py +69 -0
  119. package/template/scripts/dev_environment_lib.py +601 -0
  120. package/template/scripts/doc_profile_lib.py +415 -0
  121. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  122. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  123. package/template/scripts/guard_installer_publish.py +88 -0
  124. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  125. package/template/scripts/intake_bug_routing_guard.py +67 -0
  126. package/template/scripts/intake_evidence_lib.py +802 -0
  127. package/template/scripts/intake_evidence_validate.py +73 -0
  128. package/template/scripts/materialize_codebase_map.py +184 -0
  129. package/template/scripts/pack_json_validate.py +130 -0
  130. package/template/scripts/project_readme_coverage_lib.py +417 -0
  131. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  132. package/{scripts → template/scripts}/release-all.sh +27 -4
  133. package/template/scripts/release_changelog_backfill.py +153 -0
  134. package/template/scripts/release_changelog_lib.py +544 -0
  135. package/template/scripts/release_changelog_validate.py +134 -0
  136. package/template/scripts/remote_config_summary.py +243 -0
  137. package/template/scripts/sync_push_gates.py +198 -0
  138. package/template/scripts/token_cost_compare.py +40 -0
  139. package/template/scripts/token_cost_lib.py +108 -0
  140. package/template/scripts/uat_probe_lib.py +868 -0
  141. package/template/scripts/validate-and-push.ps1 +280 -0
  142. package/template/scripts/validate-and-push.sh +243 -0
  143. package/template/scripts/validate_doc_profile.py +103 -0
  144. package/template/scripts/validate_project_readme_coverage.py +151 -0
  145. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  146. package/template/sprints/S0001/progress.md +1 -0
  147. package/template/sprints/S0001/qa-findings.md +9 -0
  148. package/template/sprints/S0001/release-findings.md +24 -0
  149. package/template/sprints/S0001/sprint.md +9 -0
  150. package/template/sprints/S0001/summary.md +7 -0
  151. package/template/sprints/S0001/tasks.md +1 -0
  152. package/template/sprints/S0001/uat.json +6 -0
  153. package/template/sprints/S0001/uat.md +5 -0
  154. package/template/sprints/quick/Q0001/summary.md +1 -0
  155. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  156. package/.cursor/agents/curator.mdc +0 -21
  157. package/.cursor/agents/dev.mdc +0 -20
  158. package/.cursor/agents/po.mdc +0 -19
  159. package/.cursor/agents/qa.mdc +0 -19
  160. package/.cursor/agents/release.mdc +0 -19
  161. package/.cursor/agents/tech-lead.mdc +0 -21
  162. package/.cursor/commands/gsd-architecture.md +0 -29
  163. package/.cursor/commands/gsd-auto.md +0 -27
  164. package/.cursor/commands/gsd-discovery.md +0 -27
  165. package/.cursor/commands/gsd-execute.md +0 -32
  166. package/.cursor/commands/gsd-intake.md +0 -28
  167. package/.cursor/commands/gsd-map-codebase.md +0 -25
  168. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  169. package/.cursor/commands/gsd-milestone-start.md +0 -26
  170. package/.cursor/commands/gsd-pause.md +0 -25
  171. package/.cursor/commands/gsd-plan-verify.md +0 -26
  172. package/.cursor/commands/gsd-qa.md +0 -28
  173. package/.cursor/commands/gsd-quick.md +0 -24
  174. package/.cursor/commands/gsd-refresh-context.md +0 -26
  175. package/.cursor/commands/gsd-release.md +0 -29
  176. package/.cursor/commands/gsd-research.md +0 -28
  177. package/.cursor/commands/gsd-resume.md +0 -26
  178. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  179. package/.cursor/commands/gsd-verify-work.md +0 -25
  180. package/.cursor/hooks.json +0 -26
  181. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  182. package/.cursor/remote.json +0 -18
  183. package/.cursor/rules/gsd-core.mdc +0 -18
  184. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  185. package/.cursor/rules/gsd-quality.mdc +0 -15
  186. package/.cursor/scratchpad.md +0 -34
  187. package/.github/workflows/ci.yml +0 -47
  188. package/decisions/DEC-0001.md +0 -21
  189. package/decisions/DEC-0002.md +0 -21
  190. package/docs/engineering/architecture.md +0 -354
  191. package/docs/engineering/decisions.md +0 -6
  192. package/docs/engineering/research.md +0 -11
  193. package/docs/engineering/runbook.md +0 -32
  194. package/docs/engineering/state.md +0 -33
  195. package/docs/product/acceptance.md +0 -6
  196. package/docs/product/backlog.md +0 -7
  197. package/docs/product/vision.md +0 -46
  198. package/gsd-installer.ps1 +0 -189
  199. package/gsd-installer.py +0 -195
  200. package/gsd-installer.sh +0 -201
  201. package/handoffs/release_notes.md +0 -14
  202. package/handoffs/resume_brief.md +0 -8
  203. package/milestones/M0001/milestone.json +0 -7
  204. package/milestones/M0001/phases.json +0 -9
  205. package/milestones/M0001/progress.md +0 -3
  206. package/milestones/M0001/summary.md +0 -3
  207. package/scripts/generate-release-notes.ps1 +0 -74
  208. package/scripts/generate-release-notes.sh +0 -63
  209. package/scripts/release-all.ps1 +0 -423
  210. package/sprints/S0001/progress.md +0 -4
  211. package/sprints/S0001/qa-findings.md +0 -113
  212. package/sprints/S0001/sprint.md +0 -70
  213. package/sprints/S0001/summary.md +0 -46
  214. package/sprints/S0001/tasks.md +0 -35
  215. package/sprints/S0001/uat.json +0 -8
  216. package/sprints/S0001/uat.md +0 -8
  217. package/sprints/quick/Q0001/summary.md +0 -3
  218. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  230. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  231. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  232. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  233. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  234. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  235. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  236. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  237. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Idempotent one-time backfill for release changelog tiers A/B/C (US-0100 / DEC-0085).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import re
11
+ import sys
12
+
13
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ _REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
15
+ if _REPO_ROOT not in sys.path:
16
+ sys.path.insert(0, _REPO_ROOT)
17
+ if _SCRIPT_DIR not in sys.path:
18
+ sys.path.insert(0, _SCRIPT_DIR)
19
+
20
+ import release_changelog_lib as rcl # noqa: E402
21
+
22
+ try:
23
+ import yaml # type: ignore
24
+ except ImportError:
25
+ yaml = None # type: ignore
26
+
27
+
28
+ MANIFEST_REL = os.path.join(
29
+ "docs", "engineering", "context", "release-version-backfill.manifest.yaml"
30
+ )
31
+
32
+
33
+ def _load_manifest(repo_root: str) -> dict:
34
+ path = os.path.join(repo_root, MANIFEST_REL)
35
+ if not os.path.isfile(path):
36
+ return {"schema_version": 1, "entries": []}
37
+ text = rcl.read_utf8(path)
38
+ if yaml is not None:
39
+ data = yaml.safe_load(text) or {}
40
+ else:
41
+ # Minimal stdlib fallback for schema_version + entries
42
+ data = {"schema_version": 1, "entries": []}
43
+ m = re.search(r"schema_version:\s*(\d+)", text)
44
+ if m:
45
+ data["schema_version"] = int(m.group(1))
46
+ for em in re.finditer(
47
+ r"-\s*sprint_id:\s*(S\d{4})\s*\n\s*semver:\s*[\"']?([^\"'\n]+)[\"']?",
48
+ text,
49
+ ):
50
+ data.setdefault("entries", []).append(
51
+ {"sprint_id": em.group(1), "semver": em.group(2).strip()}
52
+ )
53
+ return data
54
+
55
+
56
+ def _synthetic_semver(sprint_id: str) -> str:
57
+ num = int(sprint_id[1:])
58
+ return f"0.0.0-wf.{num:03d}"
59
+
60
+
61
+ def assign_semvers(repo_root: str) -> dict[str, str]:
62
+ """Tier A → B → C precedence; returns sprint_id → semver."""
63
+ rows = sorted(
64
+ [r for r in rcl.parse_queue_rows(repo_root) if r.status == "released"],
65
+ key=lambda r: r.last_updated,
66
+ )
67
+ manifest = _load_manifest(repo_root)
68
+ tier_b: dict[str, str] = {}
69
+ for entry in manifest.get("entries") or []:
70
+ sid = entry.get("sprint_id", "").strip()
71
+ sem = entry.get("semver", "").strip()
72
+ if sid and sem:
73
+ tier_b[sid] = rcl.normalize_semver(sem)
74
+ # collision detection
75
+ inv: dict[str, list[str]] = {}
76
+ for sid, sem in tier_b.items():
77
+ inv.setdefault(sem, []).append(sid)
78
+ for sem, sids in inv.items():
79
+ if len(sids) > 1:
80
+ raise rcl.ReleaseChangelogError(
81
+ rcl.RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS,
82
+ f"manifest maps {sids} → {sem}",
83
+ )
84
+
85
+ assignment: dict[str, str] = {}
86
+ for row in rows:
87
+ if row.release_version.strip():
88
+ assignment[row.sprint_id] = rcl.normalize_semver(row.release_version)
89
+ elif row.sprint_id in tier_b:
90
+ assignment[row.sprint_id] = tier_b[row.sprint_id]
91
+ for row in rows:
92
+ if row.sprint_id in assignment:
93
+ continue
94
+ assignment[row.sprint_id] = _synthetic_semver(row.sprint_id)
95
+ return assignment
96
+
97
+
98
+ def run_backfill(repo_root: str, dry_run: bool = False) -> int:
99
+ rcl.ensure_changelog_stub(repo_root)
100
+ try:
101
+ assignment = assign_semvers(repo_root)
102
+ except rcl.ReleaseChangelogError as exc:
103
+ print(f"{exc.code}: {exc}", file=sys.stderr)
104
+ return 1
105
+
106
+ # coalesce by semver
107
+ groups: dict[str, list[str]] = {}
108
+ for sid, sem in assignment.items():
109
+ groups.setdefault(sem, []).append(sid)
110
+
111
+ for semver, sprint_ids in sorted(groups.items()):
112
+ if dry_run:
113
+ print(f"would build {semver} <- {sprint_ids}")
114
+ continue
115
+ try:
116
+ rcl.build_version_doc(semver, sprint_ids, repo_root)
117
+ rcl.promote_unreleased(semver, sprint_ids, repo_root)
118
+ rcl.bind_queue_release_version(sprint_ids, semver, repo_root)
119
+ except rcl.ReleaseChangelogError as exc:
120
+ if exc.code == rcl.RELEASE_CHANGELOG_DUPLICATE_VERSION:
121
+ print(rcl.RELEASE_CHANGELOG_IDEMPOTENCY_OK, file=sys.stderr)
122
+ continue
123
+ print(f"{exc.code}: {exc}", file=sys.stderr)
124
+ return 1
125
+
126
+ print("[RELEASE_CHANGELOG_BACKFILL_OK]")
127
+ return 0
128
+
129
+
130
+ def main() -> int:
131
+ parser = argparse.ArgumentParser(description=__doc__)
132
+ parser.add_argument("--repo", default=_REPO_ROOT)
133
+ parser.add_argument("--dry-run", action="store_true")
134
+ parser.add_argument(
135
+ "--ensure-version",
136
+ metavar="SEMVER",
137
+ help="Ensure handoffs/releases/{semver}-release-notes.md exists (release-all.sh).",
138
+ )
139
+ args = parser.parse_args()
140
+ root = os.path.abspath(args.repo)
141
+ if args.ensure_version:
142
+ try:
143
+ path = rcl.ensure_version_doc_for_release(args.ensure_version, root)
144
+ print(path)
145
+ return 0
146
+ except rcl.ReleaseChangelogError as exc:
147
+ print(f"{exc.code}: {exc}", file=sys.stderr)
148
+ return 1
149
+ return run_backfill(root, dry_run=args.dry_run)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
@@ -0,0 +1,544 @@
1
+ """
2
+ Release changelog derivation, coalesce, promote, fingerprint idempotency (US-0100 / DEC-0085).
3
+
4
+ Derivation precedence (L4):
5
+ 1. Sprint note ``## What's new`` bullets + inline US-xxxx / BUG-xxxx refs
6
+ 2. Backlog ``## US-xxxx`` / ``### BUG-xxxx`` title + summary (one-liner)
7
+ 3. Queue row ``story_refs`` (fallback when sprint note sparse)
8
+
9
+ Category map: US→Added, BUG→Fixed, user_visible:false→Changed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from typing import Dict, List, Optional, Sequence, Tuple
21
+
22
+ # Derivation precedence (documented L4 order — do not reorder without DEC revision)
23
+ DERIVATION_PRECEDENCE: Tuple[str, ...] = (
24
+ "sprint_notes_whats_new",
25
+ "backlog_title_summary",
26
+ "queue_story_refs",
27
+ )
28
+
29
+ US_ID = re.compile(r"\bUS-\d{4}\b")
30
+ BUG_ID = re.compile(r"\bBUG-\d{4}\b")
31
+ SEMVER_SECTION = re.compile(r"^##\s+\[(.+?)\]\s*(?:-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
32
+ USER_VISIBLE = re.compile(r"^-\s*user_visible:\s*(true|false)\s*$", re.IGNORECASE | re.MULTILINE)
33
+ SEMVER_VALID = re.compile(
34
+ r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
35
+ )
36
+
37
+ # Fail-closed reason codes (RELEASE_CHANGELOG_* family)
38
+ RELEASE_CHANGELOG_VERSION_MISSING = "RELEASE_CHANGELOG_VERSION_MISSING"
39
+ RELEASE_CHANGELOG_DUPLICATE_VERSION = "RELEASE_CHANGELOG_DUPLICATE_VERSION"
40
+ RELEASE_CHANGELOG_WORK_ITEM_GAP = "RELEASE_CHANGELOG_WORK_ITEM_GAP"
41
+ RELEASE_CHANGELOG_ORDER_INVALID = "RELEASE_CHANGELOG_ORDER_INVALID"
42
+ RELEASE_CHANGELOG_UNRELEASED_MISSING = "RELEASE_CHANGELOG_UNRELEASED_MISSING"
43
+ RELEASE_CHANGELOG_QUEUE_DRIFT = "RELEASE_CHANGELOG_QUEUE_DRIFT"
44
+ RELEASE_CHANGELOG_VERSION_DOC_MISSING = "RELEASE_CHANGELOG_VERSION_DOC_MISSING"
45
+ RELEASE_CHANGELOG_SPRINT_ORPHAN = "RELEASE_CHANGELOG_SPRINT_ORPHAN"
46
+ RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS = "RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS"
47
+ RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION = "RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION"
48
+ RELEASE_CHANGELOG_IDEMPOTENCY_OK = "RELEASE_CHANGELOG_IDEMPOTENCY_OK"
49
+
50
+ FAIL_CODES = (
51
+ RELEASE_CHANGELOG_VERSION_MISSING,
52
+ RELEASE_CHANGELOG_DUPLICATE_VERSION,
53
+ RELEASE_CHANGELOG_WORK_ITEM_GAP,
54
+ RELEASE_CHANGELOG_ORDER_INVALID,
55
+ RELEASE_CHANGELOG_UNRELEASED_MISSING,
56
+ RELEASE_CHANGELOG_QUEUE_DRIFT,
57
+ RELEASE_CHANGELOG_VERSION_DOC_MISSING,
58
+ RELEASE_CHANGELOG_SPRINT_ORPHAN,
59
+ RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS,
60
+ RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION,
61
+ )
62
+
63
+
64
+ class ReleaseChangelogError(Exception):
65
+ def __init__(self, code: str, message: str = "") -> None:
66
+ self.code = code
67
+ super().__init__(message or code)
68
+
69
+
70
+ @dataclass
71
+ class WorkItem:
72
+ item_id: str
73
+ title: str
74
+ summary: str
75
+ category: str # Added | Fixed | Changed
76
+ source: str = ""
77
+
78
+
79
+ @dataclass
80
+ class QueueRow:
81
+ sprint_id: str
82
+ story_refs: str
83
+ status: str
84
+ last_updated: str
85
+ release_notes_ref: str
86
+ release_version: str
87
+ raw_line: str
88
+
89
+
90
+ def read_utf8(path: str) -> str:
91
+ with open(path, "r", encoding="utf-8") as f:
92
+ return f.read()
93
+
94
+
95
+ def write_utf8(path: str, content: str) -> None:
96
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
97
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
98
+ f.write(content)
99
+
100
+
101
+ def normalize_semver(raw: str) -> str:
102
+ """Strip leading v; semver-parse; raise VERSION_MISSING on empty/invalid."""
103
+ if raw is None:
104
+ raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "version is None")
105
+ s = raw.strip()
106
+ if s.lower().startswith("v"):
107
+ s = s[1:]
108
+ if not s:
109
+ raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "empty version")
110
+ if not SEMVER_VALID.match(s):
111
+ raise ReleaseChangelogError(
112
+ RELEASE_CHANGELOG_VERSION_MISSING, f"invalid semver: {raw!r}"
113
+ )
114
+ return s
115
+
116
+
117
+ def version_fingerprint(semver: str, work_item_ids: Sequence[str]) -> str:
118
+ """Idempotency key: semver + sorted work_item_ids."""
119
+ norm = normalize_semver(semver)
120
+ ids = sorted(set(work_item_ids))
121
+ return f"{norm}|{','.join(ids)}"
122
+
123
+
124
+ def _one_liner(text: str, max_len: int = 160) -> str:
125
+ line = " ".join(text.split())
126
+ if len(line) > max_len:
127
+ return line[: max_len - 3] + "..."
128
+ return line
129
+
130
+
131
+ def parse_queue_rows(repo_root: str) -> List[QueueRow]:
132
+ path = os.path.join(repo_root, "handoffs", "release_queue.md")
133
+ if not os.path.isfile(path):
134
+ return []
135
+ rows: List[QueueRow] = []
136
+ for line in read_utf8(path).splitlines():
137
+ if not line.startswith("| S"):
138
+ continue
139
+ parts = [p.strip() for p in line.split("|")]
140
+ if len(parts) < 9:
141
+ continue
142
+ rows.append(
143
+ QueueRow(
144
+ sprint_id=parts[1],
145
+ story_refs=parts[2],
146
+ status=parts[3],
147
+ last_updated=parts[4],
148
+ release_notes_ref=parts[5],
149
+ release_version=parts[7] if len(parts) > 7 else "",
150
+ raw_line=line,
151
+ )
152
+ )
153
+ return rows
154
+
155
+
156
+ def _queue_row_map(repo_root: str) -> Dict[str, QueueRow]:
157
+ return {r.sprint_id: r for r in parse_queue_rows(repo_root)}
158
+
159
+
160
+ def _parse_backlog_items(repo_root: str) -> Dict[str, WorkItem]:
161
+ backlog_path = os.path.join(repo_root, "docs", "product", "backlog.md")
162
+ if not os.path.isfile(backlog_path):
163
+ return {}
164
+ text = read_utf8(backlog_path)
165
+ items: Dict[str, WorkItem] = {}
166
+ for m in re.finditer(r"^##\s+(US-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
167
+ item_id, title = m.group(1), m.group(2).strip()
168
+ block = text[m.end() : text.find("\n## ", m.end())]
169
+ uv = USER_VISIBLE.search(block)
170
+ user_vis = uv.group(1).lower() == "true" if uv else True
171
+ sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
172
+ summary = _one_liner(sm.group(1)) if sm else title
173
+ if not user_vis:
174
+ cat = "Changed"
175
+ else:
176
+ cat = "Added"
177
+ items[item_id] = WorkItem(item_id, title, summary, cat, "backlog_title_summary")
178
+ for m in re.finditer(r"^###\s+(BUG-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
179
+ item_id, title = m.group(1), m.group(2).strip()
180
+ block = text[m.end() : text.find("\n### ", m.end())]
181
+ block = block.split("\n## ")[0]
182
+ sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
183
+ summary = _one_liner(sm.group(1)) if sm else title
184
+ items[item_id] = WorkItem(item_id, title, summary, "Fixed", "backlog_title_summary")
185
+ return items
186
+
187
+
188
+ def _extract_whats_new(repo_root: str, sprint_id: str) -> List[str]:
189
+ notes_path = os.path.join(
190
+ repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
191
+ )
192
+ if not os.path.isfile(notes_path):
193
+ return []
194
+ text = read_utf8(notes_path)
195
+ m = re.search(r"^##\s+What's new\s*$", text, re.MULTILINE | re.IGNORECASE)
196
+ if not m:
197
+ return []
198
+ rest = text[m.end() :]
199
+ end = re.search(r"^##\s+", rest, re.MULTILINE)
200
+ section = rest[: end.start()] if end else rest
201
+ bullets: List[str] = []
202
+ for line in section.splitlines():
203
+ s = line.strip()
204
+ if s.startswith("- "):
205
+ bullets.append(s[2:].strip())
206
+ return bullets
207
+
208
+
209
+ def _category_for_id(item_id: str, backlog: Dict[str, WorkItem]) -> str:
210
+ if item_id in backlog:
211
+ return backlog[item_id].category
212
+ if item_id.startswith("BUG-"):
213
+ return "Fixed"
214
+ return "Added"
215
+
216
+
217
+ def derive_work_items(sprint_ids: Sequence[str], repo_root: str) -> List[WorkItem]:
218
+ """L4 precedence: sprint notes → backlog → queue story_refs."""
219
+ backlog = _parse_backlog_items(repo_root)
220
+ qmap = _queue_row_map(repo_root)
221
+ seen: Dict[str, WorkItem] = {}
222
+
223
+ for sprint_id in sprint_ids:
224
+ # 1. Sprint notes What's new
225
+ for bullet in _extract_whats_new(repo_root, sprint_id):
226
+ for pat in (US_ID, BUG_ID):
227
+ for item_id in pat.findall(bullet):
228
+ if item_id in seen:
229
+ continue
230
+ title = backlog[item_id].title if item_id in backlog else item_id
231
+ summary = _one_liner(bullet) if bullet else title
232
+ seen[item_id] = WorkItem(
233
+ item_id,
234
+ title,
235
+ summary,
236
+ _category_for_id(item_id, backlog),
237
+ "sprint_notes_whats_new",
238
+ )
239
+
240
+ # 2. Backlog for refs mentioned in sprint notes full text
241
+ notes_path = os.path.join(
242
+ repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
243
+ )
244
+ if os.path.isfile(notes_path):
245
+ note_text = read_utf8(notes_path)
246
+ for item_id in US_ID.findall(note_text) + BUG_ID.findall(note_text):
247
+ if item_id in seen:
248
+ continue
249
+ if item_id in backlog:
250
+ wi = backlog[item_id]
251
+ seen[item_id] = WorkItem(
252
+ wi.item_id, wi.title, wi.summary, wi.category, wi.source
253
+ )
254
+
255
+ # 3. Queue story_refs fallback
256
+ row = qmap.get(sprint_id)
257
+ if row:
258
+ for token in re.split(r"[,;\s]+", row.story_refs):
259
+ token = token.strip()
260
+ if not token or token in seen:
261
+ continue
262
+ if token in backlog:
263
+ wi = backlog[token]
264
+ seen[token] = WorkItem(
265
+ wi.item_id, wi.title, wi.summary, wi.category, "queue_story_refs"
266
+ )
267
+ elif US_ID.fullmatch(token) or BUG_ID.fullmatch(token):
268
+ seen[token] = WorkItem(
269
+ token,
270
+ token,
271
+ token,
272
+ _category_for_id(token, backlog),
273
+ "queue_story_refs",
274
+ )
275
+
276
+ def sort_key(w: WorkItem) -> Tuple[int, int]:
277
+ if w.item_id.startswith("US-"):
278
+ return (0, int(w.item_id.split("-")[1]))
279
+ return (1, int(w.item_id.split("-")[1]))
280
+
281
+ return sorted(seen.values(), key=sort_key)
282
+
283
+
284
+ def coalesce_sprints_by_semver(
285
+ rows: Optional[Sequence[QueueRow]], repo_root: str
286
+ ) -> Dict[str, List[str]]:
287
+ """Group released queue rows by normalized semver (non-empty release_version)."""
288
+ if rows is None:
289
+ rows = parse_queue_rows(repo_root)
290
+ groups: Dict[str, List[str]] = {}
291
+ for row in rows:
292
+ if row.status != "released":
293
+ continue
294
+ if not row.release_version.strip():
295
+ continue
296
+ try:
297
+ key = normalize_semver(row.release_version)
298
+ except ReleaseChangelogError:
299
+ continue
300
+ groups.setdefault(key, []).append(row.sprint_id)
301
+ for key in groups:
302
+ groups[key] = sorted(set(groups[key]))
303
+ return groups
304
+
305
+
306
+ def version_doc_path(repo_root: str, semver: str) -> str:
307
+ norm = normalize_semver(semver)
308
+ return os.path.join(repo_root, "handoffs", "releases", f"{norm}-release-notes.md")
309
+
310
+
311
+ def _format_version_doc(
312
+ semver: str,
313
+ work_items: Sequence[WorkItem],
314
+ sprint_ids: Sequence[str],
315
+ fingerprint: str,
316
+ ) -> str:
317
+ lines = [
318
+ f"# Release notes — {normalize_semver(semver)}",
319
+ "",
320
+ f"<!-- release_changelog_fingerprint: {fingerprint} -->",
321
+ "",
322
+ "> Per-version GitHub `-F` SOT (**US-0100**). Sprint-scoped evidence remains in "
323
+ "`handoffs/releases/Sxxxx-release-notes.md` — do not overwrite unrelated version files.",
324
+ "",
325
+ "## Work items",
326
+ "",
327
+ ]
328
+ for wi in work_items:
329
+ lines.append(f"- **{wi.item_id}** — {wi.summary}")
330
+ lines.extend(["", "## Sprint evidence", ""])
331
+ for sid in sorted(set(sprint_ids)):
332
+ lines.append(f"- [`{sid}`](handoffs/releases/{sid}-release-notes.md)")
333
+ lines.append("")
334
+ return "\n".join(lines)
335
+
336
+
337
+ def build_version_doc(semver: str, sprint_ids: Sequence[str], repo_root: str) -> str:
338
+ """Write per-version doc; return path. Idempotent on matching fingerprint."""
339
+ norm = normalize_semver(semver)
340
+ work_items = derive_work_items(sprint_ids, repo_root)
341
+ fp = version_fingerprint(norm, [w.item_id for w in work_items])
342
+ path = version_doc_path(repo_root, norm)
343
+
344
+ if os.path.isfile(path):
345
+ existing = read_utf8(path)
346
+ m = re.search(r"<!-- release_changelog_fingerprint:\s*(.+?)\s*-->", existing)
347
+ if m and m.group(1).strip() == fp:
348
+ print(RELEASE_CHANGELOG_IDEMPOTENCY_OK, file=__import__("sys").stderr)
349
+ return path
350
+ if m and m.group(1).strip() != fp:
351
+ raise ReleaseChangelogError(
352
+ RELEASE_CHANGELOG_DUPLICATE_VERSION,
353
+ f"fingerprint mismatch for {norm}",
354
+ )
355
+
356
+ content = _format_version_doc(norm, work_items, sprint_ids, fp)
357
+ write_utf8(path, content)
358
+ return path
359
+
360
+
361
+ def changelog_path(repo_root: str) -> str:
362
+ return os.path.join(repo_root, "CHANGELOG.md")
363
+
364
+
365
+ def ensure_changelog_stub(repo_root: str) -> None:
366
+ path = changelog_path(repo_root)
367
+ if os.path.isfile(path):
368
+ return
369
+ stub = (
370
+ "# Changelog\n\n"
371
+ "All notable changes to this project will be documented in this file.\n\n"
372
+ "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
373
+ "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
374
+ "<!-- semver-sections-newest-first -->\n\n"
375
+ "## [Unreleased]\n\n"
376
+ )
377
+ write_utf8(path, stub)
378
+
379
+
380
+ def extract_changelog_section(semver: str, repo_root: str) -> Optional[str]:
381
+ """Read cumulative section text for semver (body only, no heading)."""
382
+ path = changelog_path(repo_root)
383
+ if not os.path.isfile(path):
384
+ return None
385
+ norm = normalize_semver(semver)
386
+ text = read_utf8(path)
387
+ pattern = re.compile(
388
+ rf"^##\s+\[{re.escape(norm)}\]\s*(?:-\s*\d{{4}}-\d{{2}}-\d{{2}})?\s*$",
389
+ re.MULTILINE,
390
+ )
391
+ m = pattern.search(text)
392
+ if not m:
393
+ return None
394
+ rest = text[m.end() :]
395
+ nxt = re.search(r"^##\s+\[", rest, re.MULTILINE)
396
+ body = rest[: nxt.start()] if nxt else rest
397
+ return body.strip()
398
+
399
+
400
+ def _split_changelog_sections(text: str) -> Tuple[str, str, List[Tuple[str, str, str]]]:
401
+ """Return (header, unreleased_body, [(semver, date, body), ...])."""
402
+ m = re.search(r"^##\s+\[Unreleased\]\s*$", text, re.MULTILINE)
403
+ if not m:
404
+ raise ReleaseChangelogError(RELEASE_CHANGELOG_UNRELEASED_MISSING)
405
+ header = text[: m.start()]
406
+ rest = text[m.end() :]
407
+ sections: List[Tuple[str, str, str]] = []
408
+ pos = 0
409
+ for sm in SEMVER_SECTION.finditer(rest):
410
+ if sm.start() > pos:
411
+ # content before first semver section is unreleased body
412
+ pass
413
+ semver_raw = sm.group(1)
414
+ if semver_raw.lower() == "unreleased":
415
+ continue
416
+ date = sm.group(2) or ""
417
+ start = sm.end()
418
+ nxt = SEMVER_SECTION.search(rest, start)
419
+ body = rest[start : nxt.start() if nxt else len(rest)]
420
+ sections.append((semver_raw, date, body.strip()))
421
+ unreleased_end = SEMVER_SECTION.search(rest)
422
+ unreleased_body = rest[: unreleased_end.start()].strip() if unreleased_end else rest.strip()
423
+ return header, unreleased_body, sections
424
+
425
+
426
+ def _format_work_items_changelog(work_items: Sequence[WorkItem]) -> str:
427
+ by_cat: Dict[str, List[WorkItem]] = {"Added": [], "Fixed": [], "Changed": []}
428
+ for wi in work_items:
429
+ by_cat.setdefault(wi.category, []).append(wi)
430
+ parts: List[str] = []
431
+ for cat in ("Added", "Fixed", "Changed"):
432
+ items = by_cat.get(cat) or []
433
+ if not items:
434
+ continue
435
+ parts.append(f"### {cat}")
436
+ parts.append("")
437
+ for wi in items:
438
+ parts.append(f"- **{wi.item_id}** — {wi.summary}")
439
+ parts.append("")
440
+ return "\n".join(parts).strip()
441
+
442
+
443
+ def append_unreleased(work_items: Sequence[WorkItem], repo_root: str) -> None:
444
+ """Append categorized bullets under top [Unreleased] only."""
445
+ ensure_changelog_stub(repo_root)
446
+ path = changelog_path(repo_root)
447
+ text = read_utf8(path)
448
+ header, unreleased_body, sections = _split_changelog_sections(text)
449
+ new_block = _format_work_items_changelog(work_items)
450
+ if new_block:
451
+ if unreleased_body:
452
+ unreleased_body = unreleased_body + "\n\n" + new_block
453
+ else:
454
+ unreleased_body = new_block
455
+ out = header + "## [Unreleased]\n\n"
456
+ if unreleased_body:
457
+ out += unreleased_body + "\n\n"
458
+ for semver, date, body in sections:
459
+ out += f"## [{semver}]"
460
+ if date:
461
+ out += f" - {date}"
462
+ out += "\n\n" + body + "\n\n"
463
+ write_utf8(path, out.rstrip() + "\n")
464
+
465
+
466
+ def promote_unreleased(
467
+ semver: str,
468
+ sprint_ids: Sequence[str],
469
+ repo_root: str,
470
+ release_date: Optional[str] = None,
471
+ ) -> None:
472
+ """Move [Unreleased] items into ## [semver] - date; recreate empty [Unreleased]."""
473
+ ensure_changelog_stub(repo_root)
474
+ path = changelog_path(repo_root)
475
+ text = read_utf8(path)
476
+ header, unreleased_body, sections = _split_changelog_sections(text)
477
+ norm = normalize_semver(semver)
478
+ work_items = derive_work_items(sprint_ids, repo_root)
479
+ promoted = _format_work_items_changelog(work_items)
480
+ if unreleased_body:
481
+ promoted = (unreleased_body + "\n\n" + promoted).strip() if promoted else unreleased_body
482
+ date = release_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
483
+ # Remove existing section for same semver
484
+ sections = [(s, d, b) for s, d, b in sections if normalize_semver(s) != norm]
485
+ sections.insert(0, (norm, date, promoted))
486
+ out = header + "## [Unreleased]\n\n\n"
487
+ for s, d, b in sections:
488
+ out += f"## [{s}]"
489
+ if d:
490
+ out += f" - {d}"
491
+ out += "\n\n" + b + "\n\n"
492
+ write_utf8(path, out.rstrip() + "\n")
493
+
494
+
495
+ def bind_queue_release_version(
496
+ sprint_ids: Sequence[str], semver: str, repo_root: str
497
+ ) -> None:
498
+ """Mutate only specified sprint rows in handoffs/release_queue.md."""
499
+ path = os.path.join(repo_root, "handoffs", "release_queue.md")
500
+ if not os.path.isfile(path):
501
+ raise ReleaseChangelogError(
502
+ RELEASE_CHANGELOG_QUEUE_DRIFT, "release_queue.md missing"
503
+ )
504
+ norm = normalize_semver(semver)
505
+ targets = set(sprint_ids)
506
+ lines = read_utf8(path).splitlines()
507
+ out: List[str] = []
508
+ for line in lines:
509
+ if not line.startswith("| S"):
510
+ out.append(line)
511
+ continue
512
+ cols = [p.strip() for p in line.split("|")[1:-1]]
513
+ if len(cols) < 7:
514
+ out.append(line)
515
+ continue
516
+ if cols[0] in targets:
517
+ cols[6] = norm
518
+ out.append("| " + " | ".join(cols) + " |")
519
+ else:
520
+ out.append(line)
521
+ write_utf8(path, "\n".join(out) + "\n")
522
+
523
+
524
+ def ensure_version_doc_for_release(semver: str, repo_root: str) -> str:
525
+ """Coalesce released rows for semver (or all unbound since tag) and build doc."""
526
+ norm = normalize_semver(semver)
527
+ rows = parse_queue_rows(repo_root)
528
+ sprint_ids: List[str] = []
529
+ for row in rows:
530
+ if row.status != "released":
531
+ continue
532
+ rv = row.release_version.strip()
533
+ if rv and normalize_semver(rv) == norm:
534
+ sprint_ids.append(row.sprint_id)
535
+ elif not rv:
536
+ sprint_ids.append(row.sprint_id)
537
+ if not sprint_ids:
538
+ sprint_ids = [r.sprint_id for r in rows if r.status == "released"][-1:]
539
+ return build_version_doc(norm, sprint_ids, repo_root)
540
+
541
+
542
+ def runtime_proof_hash(payload: Dict[str, object]) -> str:
543
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
544
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()