its-magic 0.1.2-9 → 0.1.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 (232) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +21 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +981 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +284 -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 +505 -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/README.md +1602 -0
  65. package/template/decisions/DEC-0001.md +11 -0
  66. package/template/decisions/DEC-0002.md +11 -0
  67. package/template/docs/developer/README.md +44 -0
  68. package/template/docs/engineering/architecture.md +9 -0
  69. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  70. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  71. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  72. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  73. package/template/docs/engineering/compatibility-report.md +20 -0
  74. package/template/docs/engineering/compatibility-signals.md +7 -0
  75. package/template/docs/engineering/component-scope-report.md +16 -0
  76. package/template/docs/engineering/component-scope.md +19 -0
  77. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  78. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  79. package/template/docs/engineering/decisions.md +16 -0
  80. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  81. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  82. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  83. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  84. package/template/docs/engineering/phase-context.md +16 -0
  85. package/template/docs/engineering/release-targets.json +123 -0
  86. package/template/docs/engineering/research.md +29 -0
  87. package/template/docs/engineering/runbook.md +2320 -0
  88. package/template/docs/engineering/runtime-connectivity.md +81 -0
  89. package/template/docs/engineering/security-review.md +33 -0
  90. package/template/docs/engineering/spec-pack/README.md +20 -0
  91. package/template/docs/engineering/state-archive/README.md +15 -0
  92. package/template/docs/engineering/state.md +20 -0
  93. package/template/docs/engineering/status-normalization-report.md +19 -0
  94. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  95. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  96. package/template/docs/product/acceptance.md +1 -0
  97. package/template/docs/product/backlog.md +5 -0
  98. package/template/docs/product/vision.md +11 -0
  99. package/template/docs/user-guides/README.md +40 -0
  100. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  101. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  102. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  103. package/template/handoffs/release_notes.md +51 -0
  104. package/template/handoffs/release_queue.md +47 -0
  105. package/template/handoffs/release_to_dev.md +31 -0
  106. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  107. package/template/handoffs/resume_brief.md +14 -0
  108. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  109. package/template/handoffs/token_cost_runs/README.md +26 -0
  110. package/template/its_magic/.its-magic-version +1 -0
  111. package/template/its_magic/README.md +9 -0
  112. package/template/scripts/auto_outer_driver.py +521 -0
  113. package/template/scripts/caveman_compress_input.py +903 -0
  114. package/template/scripts/check_downstream_ci_guard.py +67 -0
  115. package/template/scripts/check_intake_template_parity.py +284 -0
  116. package/template/scripts/check_token_cost_parity.py +69 -0
  117. package/template/scripts/dev_environment_lib.py +463 -0
  118. package/template/scripts/doc_profile_lib.py +415 -0
  119. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  120. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  121. package/template/scripts/guard_installer_publish.py +88 -0
  122. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  123. package/template/scripts/intake_bug_routing_guard.py +67 -0
  124. package/template/scripts/intake_evidence_lib.py +802 -0
  125. package/template/scripts/intake_evidence_validate.py +73 -0
  126. package/template/scripts/materialize_codebase_map.py +184 -0
  127. package/template/scripts/pack_json_validate.py +130 -0
  128. package/template/scripts/project_readme_coverage_lib.py +417 -0
  129. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  130. package/template/scripts/remote_config_summary.py +243 -0
  131. package/template/scripts/sync_push_gates.py +198 -0
  132. package/template/scripts/token_cost_compare.py +40 -0
  133. package/template/scripts/token_cost_lib.py +108 -0
  134. package/template/scripts/uat_probe_lib.py +868 -0
  135. package/template/scripts/validate-and-push.ps1 +280 -0
  136. package/template/scripts/validate-and-push.sh +243 -0
  137. package/template/scripts/validate_doc_profile.py +103 -0
  138. package/template/scripts/validate_project_readme_coverage.py +151 -0
  139. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  140. package/template/sprints/S0001/progress.md +1 -0
  141. package/template/sprints/S0001/qa-findings.md +9 -0
  142. package/template/sprints/S0001/release-findings.md +24 -0
  143. package/template/sprints/S0001/sprint.md +9 -0
  144. package/template/sprints/S0001/summary.md +7 -0
  145. package/template/sprints/S0001/tasks.md +1 -0
  146. package/template/sprints/S0001/uat.json +6 -0
  147. package/template/sprints/S0001/uat.md +5 -0
  148. package/template/sprints/quick/Q0001/summary.md +1 -0
  149. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  150. package/.cursor/agents/curator.mdc +0 -21
  151. package/.cursor/agents/dev.mdc +0 -20
  152. package/.cursor/agents/po.mdc +0 -19
  153. package/.cursor/agents/qa.mdc +0 -19
  154. package/.cursor/agents/release.mdc +0 -19
  155. package/.cursor/agents/tech-lead.mdc +0 -21
  156. package/.cursor/commands/gsd-architecture.md +0 -29
  157. package/.cursor/commands/gsd-auto.md +0 -27
  158. package/.cursor/commands/gsd-discovery.md +0 -27
  159. package/.cursor/commands/gsd-execute.md +0 -32
  160. package/.cursor/commands/gsd-intake.md +0 -28
  161. package/.cursor/commands/gsd-map-codebase.md +0 -25
  162. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  163. package/.cursor/commands/gsd-milestone-start.md +0 -26
  164. package/.cursor/commands/gsd-pause.md +0 -25
  165. package/.cursor/commands/gsd-plan-verify.md +0 -26
  166. package/.cursor/commands/gsd-qa.md +0 -28
  167. package/.cursor/commands/gsd-quick.md +0 -24
  168. package/.cursor/commands/gsd-refresh-context.md +0 -26
  169. package/.cursor/commands/gsd-release.md +0 -29
  170. package/.cursor/commands/gsd-research.md +0 -28
  171. package/.cursor/commands/gsd-resume.md +0 -26
  172. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  173. package/.cursor/commands/gsd-verify-work.md +0 -25
  174. package/.cursor/hooks.json +0 -26
  175. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  176. package/.cursor/remote.json +0 -18
  177. package/.cursor/rules/gsd-core.mdc +0 -18
  178. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  179. package/.cursor/rules/gsd-quality.mdc +0 -15
  180. package/.cursor/scratchpad.md +0 -34
  181. package/.github/workflows/ci.yml +0 -47
  182. package/decisions/DEC-0001.md +0 -21
  183. package/decisions/DEC-0002.md +0 -21
  184. package/docs/engineering/architecture.md +0 -354
  185. package/docs/engineering/decisions.md +0 -6
  186. package/docs/engineering/research.md +0 -11
  187. package/docs/engineering/runbook.md +0 -32
  188. package/docs/engineering/state.md +0 -33
  189. package/docs/product/acceptance.md +0 -6
  190. package/docs/product/backlog.md +0 -7
  191. package/docs/product/vision.md +0 -46
  192. package/gsd-installer.ps1 +0 -189
  193. package/gsd-installer.py +0 -195
  194. package/gsd-installer.sh +0 -201
  195. package/handoffs/release_notes.md +0 -14
  196. package/handoffs/resume_brief.md +0 -8
  197. package/milestones/M0001/milestone.json +0 -7
  198. package/milestones/M0001/phases.json +0 -9
  199. package/milestones/M0001/progress.md +0 -3
  200. package/milestones/M0001/summary.md +0 -3
  201. package/scripts/generate-release-notes.ps1 +0 -74
  202. package/scripts/generate-release-notes.sh +0 -63
  203. package/scripts/release-all.ps1 +0 -423
  204. package/scripts/release-all.sh +0 -226
  205. package/sprints/S0001/progress.md +0 -4
  206. package/sprints/S0001/qa-findings.md +0 -113
  207. package/sprints/S0001/sprint.md +0 -70
  208. package/sprints/S0001/summary.md +0 -46
  209. package/sprints/S0001/tasks.md +0 -35
  210. package/sprints/S0001/uat.json +0 -8
  211. package/sprints/S0001/uat.md +0 -8
  212. package/sprints/quick/Q0001/summary.md +0 -3
  213. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  214. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  215. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  216. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  217. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  218. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  230. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  231. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  232. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,753 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deterministic hot-surface enforcement for the engineering triad (DEC-0054).
4
+
5
+ Surfaces:
6
+ docs/engineering/state.md
7
+ handoffs/po_to_tl.md
8
+ docs/engineering/architecture.md
9
+
10
+ Thresholds resolve from merged .cursor/scratchpad.md + scratchpad.local.md.
11
+
12
+ Modes:
13
+ --check fail closed if any surface exceeds policy (exit 1)
14
+ --rollover archive oldest material into deterministic packs; idempotent
15
+ --self-test built-in regression (temp fixtures, no repo mutation)
16
+
17
+ User-facing diagnostics avoid planning-shaped tokens; use paths + reason codes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ import tempfile
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional, Sequence, Tuple
30
+
31
+ DEFAULTS = {
32
+ "STATE_HOT_MAX_LINES": "1200",
33
+ "STATE_HOT_MAX_CHECKPOINTS": "80",
34
+ "PO_TO_TL_HOT_MAX_LINES": "800",
35
+ "PO_TO_TL_HOT_MAX_SECTIONS": "60",
36
+ "ARCH_HOT_MAX_LINES": "3500",
37
+ "ARCH_HOT_MAX_STORY_SECTIONS": "120",
38
+ }
39
+
40
+ STATE_REL = Path("docs/engineering/state.md")
41
+ PO_REL = Path("handoffs/po_to_tl.md")
42
+ ARCH_REL = Path("docs/engineering/architecture.md")
43
+ STATE_ARCH_DIR = Path("docs/engineering/state-archive")
44
+ PO_ARCH_DIR = Path("handoffs/archive")
45
+ ARCH_ARCH_DIR = Path("docs/engineering/architecture-archive")
46
+
47
+ CHECKPOINT_HEADING = re.compile(r"^## .*\bcheckpoint\b.*$", re.I)
48
+ STORY_HEADING_H1 = re.compile(r"^# (?:US|BUG)-\d{4}\s*[:\u2014\-].+$")
49
+ STORY_HEADING_H2 = re.compile(r"^## US-\d{4}\s*[:\u2014\-].+$")
50
+ _STORY_ID_FROM_LINE = re.compile(r"(?:US|BUG)-\d{4}")
51
+
52
+
53
+ class PolicyError(Exception):
54
+ def __init__(self, code: str, message: str) -> None:
55
+ super().__init__(message)
56
+ self.code = code
57
+ self.message = message
58
+
59
+
60
+ def _repo_root(cli: Optional[str]) -> Path:
61
+ if cli:
62
+ return Path(cli).resolve()
63
+ return Path(__file__).resolve().parent.parent
64
+
65
+
66
+ def _parse_scratchpad_text(text: str, into: Dict[str, str]) -> None:
67
+ for raw in text.splitlines():
68
+ line = raw.strip()
69
+ if not line or line.startswith("#"):
70
+ continue
71
+ if line.startswith("- "):
72
+ continue
73
+ if "=" not in line:
74
+ continue
75
+ key, _, val = line.partition("=")
76
+ key, val = key.strip(), val.strip()
77
+ if key and val:
78
+ into[key] = val
79
+
80
+
81
+ def load_merged_policy(repo: Path) -> Dict[str, str]:
82
+ """Merge scratchpad layers: DEFAULTS < example < baseline < local (DEC-0055)."""
83
+ out = dict(DEFAULTS)
84
+ example = repo / ".cursor" / "scratchpad.local.example.md"
85
+ base = repo / ".cursor" / "scratchpad.md"
86
+ local = repo / ".cursor" / "scratchpad.local.md"
87
+ if example.is_file():
88
+ _parse_scratchpad_text(example.read_text(encoding="utf-8"), out)
89
+ if base.is_file():
90
+ _parse_scratchpad_text(base.read_text(encoding="utf-8"), out)
91
+ if local.is_file():
92
+ _parse_scratchpad_text(local.read_text(encoding="utf-8"), out)
93
+ return out
94
+
95
+
96
+ def _int_policy(policy: Dict[str, str], key: str) -> int:
97
+ try:
98
+ v = int(policy[key])
99
+ if v < 1:
100
+ raise ValueError
101
+ return v
102
+ except (KeyError, ValueError) as exc:
103
+ raise PolicyError(
104
+ "STATE_ARCHIVE_VERIFICATION_FAILED",
105
+ f"invalid or missing integer policy {key}",
106
+ ) from exc
107
+
108
+
109
+ def line_count(text: str) -> int:
110
+ if not text:
111
+ return 0
112
+ return len(text.splitlines())
113
+
114
+
115
+ def split_state_checkpoints(text: str) -> Tuple[str, List[str]]:
116
+ lines = text.splitlines(keepends=True)
117
+ idxs = [i for i, ln in enumerate(lines) if CHECKPOINT_HEADING.match(ln.rstrip("\r\n"))]
118
+ if not idxs:
119
+ return text, []
120
+ preamble = "".join(lines[: idxs[0]])
121
+ blocks: List[str] = []
122
+ for j, start in enumerate(idxs):
123
+ end = idxs[j + 1] if j + 1 < len(idxs) else len(lines)
124
+ blocks.append("".join(lines[start:end]))
125
+ return preamble, blocks
126
+
127
+
128
+ def split_po_sections(text: str) -> List[str]:
129
+ lines = text.splitlines(keepends=True)
130
+ starts = [i for i, ln in enumerate(lines) if ln.startswith("## ")]
131
+ if not starts:
132
+ return [text] if text.strip() else []
133
+ sections: List[str] = []
134
+ for j, start in enumerate(starts):
135
+ end = starts[j + 1] if j + 1 < len(starts) else len(lines)
136
+ sections.append("".join(lines[start:end]))
137
+ return sections
138
+
139
+
140
+ def _story_boundary_candidates(text: str) -> List[Tuple[int, str, int]]:
141
+ """Collect (line_idx, story_id, level) for H1/H2 story-heading matches."""
142
+ lines = text.splitlines(keepends=True)
143
+ candidates: List[Tuple[int, str, int]] = []
144
+ for i, ln in enumerate(lines):
145
+ stripped = ln.rstrip("\r\n")
146
+ if STORY_HEADING_H1.match(stripped):
147
+ m = _STORY_ID_FROM_LINE.search(stripped)
148
+ if m:
149
+ candidates.append((i, m.group(0), 1))
150
+ elif STORY_HEADING_H2.match(stripped):
151
+ m = _STORY_ID_FROM_LINE.search(stripped)
152
+ if m:
153
+ candidates.append((i, m.group(0), 2))
154
+ return candidates
155
+
156
+
157
+ def split_arch_stories(text: str) -> Tuple[str, List[str]]:
158
+ lines = text.splitlines(keepends=True)
159
+ candidates = _story_boundary_candidates(text)
160
+ if not candidates:
161
+ return text, []
162
+ h1_ids = {story_id for _, story_id, level in candidates if level == 1}
163
+ idxs = sorted(
164
+ idx
165
+ for idx, story_id, level in candidates
166
+ if not (level == 2 and story_id in h1_ids)
167
+ )
168
+ if not idxs:
169
+ return text, []
170
+ preamble = "".join(lines[: idxs[0]])
171
+ blocks: List[str] = []
172
+ for j, start in enumerate(idxs):
173
+ end = idxs[j + 1] if j + 1 < len(idxs) else len(lines)
174
+ blocks.append("".join(lines[start:end]))
175
+ return preamble, blocks
176
+
177
+
178
+ def count_h2_story_headings(text: str) -> int:
179
+ return sum(
180
+ 1 for ln in text.splitlines() if STORY_HEADING_H2.match(ln.rstrip("\r\n"))
181
+ )
182
+
183
+
184
+ def check_arch_heading_policy(text_after: str, baseline_h2_count: int) -> Optional[str]:
185
+ if count_h2_story_headings(text_after) > baseline_h2_count:
186
+ return "ARCH_STORY_HEADING_LEVEL_INVALID"
187
+ return None
188
+
189
+
190
+ def next_pack_path(repo: Path, archive_dir: Path, stem: str) -> Path:
191
+ archive_dir.mkdir(parents=True, exist_ok=True)
192
+ day = datetime.now(timezone.utc).strftime("%Y%m%d")
193
+ base = archive_dir / f"{stem}-{day}.md"
194
+ if not base.exists():
195
+ return base
196
+ alphabet = "abcdefghijklmnopqrstuvwxyz"
197
+ # Deterministic expansion: first single-letter suffixes, then double-letter.
198
+ for c in alphabet:
199
+ cand = archive_dir / f"{stem}-{day}-{c}.md"
200
+ if not cand.exists():
201
+ return cand
202
+ for c1 in alphabet:
203
+ for c2 in alphabet:
204
+ cand = archive_dir / f"{stem}-{day}-{c1}{c2}.md"
205
+ if not cand.exists():
206
+ return cand
207
+ raise PolicyError(
208
+ "STATE_ARCHIVE_WRITE_FAILED",
209
+ "exhausted deterministic pack disambiguators for today",
210
+ )
211
+
212
+
213
+ def write_pack_header(
214
+ pack_path: Path,
215
+ title: str,
216
+ source_rel: str,
217
+ trigger: str,
218
+ verification: Dict[str, object],
219
+ first_heading: str,
220
+ last_heading: str,
221
+ moved_units: int,
222
+ retained_units: int,
223
+ ) -> None:
224
+ header_lines = [
225
+ f"# {title}",
226
+ "",
227
+ f"- Rollover trigger: `{trigger}`",
228
+ f"- Source: `{source_rel}`",
229
+ f"- Archived units (oldest first, contiguous prefix): {moved_units}",
230
+ f"- Retained units in hot file: {retained_units}",
231
+ f"- First archived heading: `{first_heading}`",
232
+ f"- Last archived heading: `{last_heading}`",
233
+ "- Verification tuple (mandatory):",
234
+ ]
235
+ for k in sorted(verification.keys()):
236
+ header_lines.append(f" - {k}={verification[k]}")
237
+ header_lines.extend(["", "---", ""])
238
+ block = "\n".join(header_lines) + "\n"
239
+ pack_path.write_text(block, encoding="utf-8")
240
+
241
+
242
+ def rollover_state(repo: Path, policy: Dict[str, str], dry_run: bool) -> Optional[Dict[str, object]]:
243
+ path = repo / STATE_REL
244
+ text = path.read_text(encoding="utf-8")
245
+ max_lines = _int_policy(policy, "STATE_HOT_MAX_LINES")
246
+ max_cp = _int_policy(policy, "STATE_HOT_MAX_CHECKPOINTS")
247
+ preamble, blocks = split_state_checkpoints(text)
248
+ if not blocks:
249
+ if line_count(text) <= max_lines:
250
+ return None
251
+ raise PolicyError(
252
+ "STATE_ARCHIVE_BOUNDARY_AMBIGUOUS",
253
+ "state file exceeds line cap but has no checkpoint headings to archive",
254
+ )
255
+ if line_count(text) <= max_lines and len(blocks) <= max_cp:
256
+ return None
257
+ moved = 0
258
+ work_blocks = list(blocks)
259
+ archived_chunks: List[str] = []
260
+ while work_blocks and (
261
+ line_count(preamble + "".join(work_blocks)) > max_lines or len(work_blocks) > max_cp
262
+ ):
263
+ archived_chunks.append(work_blocks.pop(0))
264
+ moved += 1
265
+ if not archived_chunks:
266
+ return None
267
+ combined = preamble + "".join(work_blocks)
268
+ if line_count(combined) > max_lines or len(work_blocks) > max_cp:
269
+ raise PolicyError(
270
+ "ARTIFACT_HOT_SURFACE_OVERSIZE",
271
+ "state preamble or single checkpoint exceeds hot line cap; manual split required",
272
+ )
273
+ new_body = preamble + "".join(work_blocks)
274
+ first_h = archived_chunks[0].splitlines()[0].strip() if archived_chunks else ""
275
+ last_h = archived_chunks[-1].splitlines()[0].strip() if archived_chunks else ""
276
+ trigger = (
277
+ f"STATE_HOT_MAX_LINES={max_lines}, STATE_HOT_MAX_CHECKPOINTS={max_cp}"
278
+ )
279
+ pack = next_pack_path(repo, repo / STATE_ARCH_DIR, "state-pack")
280
+ ver = {
281
+ "boundary": "triad-rollover|state",
282
+ "moved": moved,
283
+ "retained_checkpoints": len(work_blocks),
284
+ "retained_lines": line_count(new_body),
285
+ "pack_ref": str(pack.as_posix()),
286
+ }
287
+ if dry_run:
288
+ return ver
289
+ archived_body = "".join(archived_chunks)
290
+ write_pack_header(
291
+ pack,
292
+ f"State archive pack ({day_stamp()})",
293
+ STATE_REL.as_posix(),
294
+ trigger,
295
+ {
296
+ "archived_body_lines": line_count(archived_body),
297
+ "retained_body_lines": line_count(new_body),
298
+ "preamble_lines": line_count(preamble),
299
+ },
300
+ first_h,
301
+ last_h,
302
+ moved,
303
+ len(work_blocks),
304
+ )
305
+ with pack.open("a", encoding="utf-8") as fh:
306
+ fh.write(archived_body)
307
+ if not archived_body.endswith("\n"):
308
+ fh.write("\n")
309
+ path.write_text(new_body, encoding="utf-8", newline="\n")
310
+ return ver
311
+
312
+
313
+ def day_stamp() -> str:
314
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
315
+
316
+
317
+ def rollover_po_to_tl(repo: Path, policy: Dict[str, str], dry_run: bool) -> Optional[Dict[str, object]]:
318
+ path = repo / PO_REL
319
+ text = path.read_text(encoding="utf-8")
320
+ max_lines = _int_policy(policy, "PO_TO_TL_HOT_MAX_LINES")
321
+ max_sec = _int_policy(policy, "PO_TO_TL_HOT_MAX_SECTIONS")
322
+ sections = split_po_sections(text)
323
+ if not sections:
324
+ if line_count(text) <= max_lines:
325
+ return None
326
+ raise PolicyError(
327
+ "STATE_ARCHIVE_BOUNDARY_AMBIGUOUS",
328
+ "handoff file exceeds line cap but has no ## sections to archive",
329
+ )
330
+ moved = 0
331
+ work = list(sections)
332
+ archived: List[str] = []
333
+ while work and (line_count("".join(work)) > max_lines or len(work) > max_sec):
334
+ archived.append(work.pop(0))
335
+ moved += 1
336
+ if not archived:
337
+ return None
338
+ new_body = "".join(work)
339
+ if line_count(new_body) > max_lines or len(work) > max_sec:
340
+ raise PolicyError(
341
+ "ARTIFACT_HOT_SURFACE_OVERSIZE",
342
+ "a single handoff section exceeds policy; manual split required",
343
+ )
344
+ first_h = archived[0].splitlines()[0].strip() if archived else ""
345
+ last_h = archived[-1].splitlines()[0].strip() if archived else ""
346
+ trigger = (
347
+ f"PO_TO_TL_HOT_MAX_LINES={max_lines}, "
348
+ f"PO_TO_TL_HOT_MAX_SECTIONS={max_sec}"
349
+ )
350
+ pack = next_pack_path(repo, repo / PO_ARCH_DIR, "po-to-tl-pack")
351
+ ver = {
352
+ "boundary": "triad-rollover|po_to_tl",
353
+ "moved": moved,
354
+ "retained_sections": len(work),
355
+ "retained_lines": line_count(new_body),
356
+ "pack_ref": str(pack.as_posix()),
357
+ }
358
+ if dry_run:
359
+ return ver
360
+ archived_body = "".join(archived)
361
+ write_pack_header(
362
+ pack,
363
+ f"PO to TL archive pack ({day_stamp()})",
364
+ PO_REL.as_posix(),
365
+ trigger,
366
+ {
367
+ "archived_body_lines": line_count(archived_body),
368
+ "retained_body_lines": line_count(new_body),
369
+ },
370
+ first_h,
371
+ last_h,
372
+ moved,
373
+ len(work),
374
+ )
375
+ with pack.open("a", encoding="utf-8") as fh:
376
+ fh.write(archived_body)
377
+ if not archived_body.endswith("\n"):
378
+ fh.write("\n")
379
+ path.write_text(new_body, encoding="utf-8", newline="\n")
380
+ return ver
381
+
382
+
383
+ def rollover_architecture(repo: Path, policy: Dict[str, str], dry_run: bool) -> Optional[Dict[str, object]]:
384
+ path = repo / ARCH_REL
385
+ text = path.read_text(encoding="utf-8")
386
+ max_lines = _int_policy(policy, "ARCH_HOT_MAX_LINES")
387
+ max_stories = _int_policy(policy, "ARCH_HOT_MAX_STORY_SECTIONS")
388
+ preamble, stories = split_arch_stories(text)
389
+ if not stories:
390
+ if line_count(text) <= max_lines:
391
+ return None
392
+ raise PolicyError(
393
+ "STATE_ARCHIVE_BOUNDARY_AMBIGUOUS",
394
+ "architecture file exceeds line cap but has no story headings to archive",
395
+ )
396
+ moved = 0
397
+ work = list(stories)
398
+ archived: List[str] = []
399
+ while work and (
400
+ line_count(preamble + "".join(work)) > max_lines or len(work) > max_stories
401
+ ):
402
+ archived.append(work.pop(0))
403
+ moved += 1
404
+ if not archived:
405
+ return None
406
+ new_body = preamble + "".join(work)
407
+ if line_count(new_body) > max_lines or len(work) > max_stories:
408
+ raise PolicyError(
409
+ "ARTIFACT_HOT_SURFACE_OVERSIZE",
410
+ "architecture preamble or single story block exceeds policy; manual split required",
411
+ )
412
+ first_h = archived[0].splitlines()[0].strip() if archived else ""
413
+ last_h = archived[-1].splitlines()[0].strip() if archived else ""
414
+ trigger = f"ARCH_HOT_MAX_LINES={max_lines}, ARCH_HOT_MAX_STORY_SECTIONS={max_stories}"
415
+ pack = next_pack_path(repo, repo / ARCH_ARCH_DIR, "architecture-pack")
416
+ ver = {
417
+ "boundary": "triad-rollover|architecture",
418
+ "moved": moved,
419
+ "retained_story_sections": len(work),
420
+ "retained_lines": line_count(new_body),
421
+ "pack_ref": str(pack.as_posix()),
422
+ }
423
+ if dry_run:
424
+ return ver
425
+ archived_body = "".join(archived)
426
+ write_pack_header(
427
+ pack,
428
+ f"Architecture archive pack ({day_stamp()})",
429
+ ARCH_REL.as_posix(),
430
+ trigger,
431
+ {
432
+ "archived_body_lines": line_count(archived_body),
433
+ "retained_body_lines": line_count(new_body),
434
+ "preamble_lines": line_count(preamble),
435
+ },
436
+ first_h,
437
+ last_h,
438
+ moved,
439
+ len(work),
440
+ )
441
+ with pack.open("a", encoding="utf-8") as fh:
442
+ fh.write(archived_body)
443
+ if not archived_body.endswith("\n"):
444
+ fh.write("\n")
445
+ path.write_text(new_body, encoding="utf-8", newline="\n")
446
+ return ver
447
+
448
+
449
+ def check_surface(
450
+ name: str,
451
+ path: Path,
452
+ lines: int,
453
+ units: int,
454
+ max_lines: int,
455
+ max_units: int,
456
+ ) -> Optional[str]:
457
+ if lines <= max_lines and units <= max_units:
458
+ return None
459
+ return (
460
+ f"STATE_ARCHIVE_REQUIRED surface={name} path={path.as_posix()} "
461
+ f"lines={lines}/{max_lines} units={units}/{max_units} "
462
+ f"reason=ARTIFACT_HOT_SURFACE_OVERSIZE"
463
+ )
464
+
465
+
466
+ def run_check(repo: Path, policy: Dict[str, str]) -> List[str]:
467
+ errors: List[str] = []
468
+ s_path = repo / STATE_REL
469
+ s_text = s_path.read_text(encoding="utf-8")
470
+ pre, cps = split_state_checkpoints(s_text)
471
+ s_lines = line_count(s_text)
472
+ max_sl = _int_policy(policy, "STATE_HOT_MAX_LINES")
473
+ max_sc = _int_policy(policy, "STATE_HOT_MAX_CHECKPOINTS")
474
+ msg = check_surface("state", STATE_REL, s_lines, len(cps), max_sl, max_sc)
475
+ if msg:
476
+ errors.append(msg)
477
+ if line_count(pre) > max_sl:
478
+ errors.append(
479
+ f"STATE_ARCHIVE_REQUIRED surface=state path={STATE_REL.as_posix()} "
480
+ f"reason=ARTIFACT_HOT_SURFACE_OVERSIZE preamble exceeds line cap"
481
+ )
482
+
483
+ p_path = repo / PO_REL
484
+ p_text = p_path.read_text(encoding="utf-8")
485
+ secs = split_po_sections(p_text)
486
+ p_lines = line_count(p_text)
487
+ max_pl = _int_policy(policy, "PO_TO_TL_HOT_MAX_LINES")
488
+ max_ps = _int_policy(policy, "PO_TO_TL_HOT_MAX_SECTIONS")
489
+ msg = check_surface("po_to_tl", PO_REL, p_lines, len(secs), max_pl, max_ps)
490
+ if msg:
491
+ errors.append(msg)
492
+
493
+ a_path = repo / ARCH_REL
494
+ a_text = a_path.read_text(encoding="utf-8")
495
+ _, stories = split_arch_stories(a_text)
496
+ a_lines = line_count(a_text)
497
+ max_al = _int_policy(policy, "ARCH_HOT_MAX_LINES")
498
+ max_as = _int_policy(policy, "ARCH_HOT_MAX_STORY_SECTIONS")
499
+ msg = check_surface("architecture", ARCH_REL, a_lines, len(stories), max_al, max_as)
500
+ if msg:
501
+ errors.append(msg)
502
+
503
+ return errors
504
+
505
+
506
+ def run_rollover_all(repo: Path, policy: Dict[str, str], dry_run: bool) -> List[Dict[str, object]]:
507
+ results: List[Dict[str, object]] = []
508
+ for fn in (rollover_state, rollover_po_to_tl, rollover_architecture):
509
+ out = fn(repo, policy, dry_run)
510
+ if out:
511
+ results.append(out)
512
+ return results
513
+
514
+
515
+ def cmd_self_test() -> int:
516
+ errors: List[str] = []
517
+
518
+ def fail(m: str) -> None:
519
+ errors.append(m)
520
+
521
+ # --- state checkpoint split + rollover ---
522
+ cp = "## Alpha checkpoint\nbody\n\n## Beta checkpoint\nmore\n"
523
+ pre, blocks = split_state_checkpoints(cp)
524
+ if pre.strip() or len(blocks) != 2:
525
+ fail("state split expected two checkpoint blocks")
526
+
527
+ # --- po sections ---
528
+ po = "## A\nx\n\n## B\ny\n\n## C\nz\n\n"
529
+ secs = split_po_sections(po)
530
+ if [line_count(s) for s in secs] != [3, 3, 3]:
531
+ fail("po section split line counts unexpected")
532
+
533
+ # --- arch stories (H1 non-regression) ---
534
+ arch = "# Preamble line\n\n# US-0001: One\nx\n# US-0002: Two\ny\n"
535
+ apre, stories = split_arch_stories(arch)
536
+ if "Preamble" not in apre or len(stories) != 2:
537
+ fail("architecture H1 story split failed")
538
+
539
+ # --- mixed H1+H2 same id: H1 wins ---
540
+ mixed = "# US-0067: Alpha\nbody\n## US-0067: Legacy\nmore\n# US-0068: Next\nz\n"
541
+ _, mixed_stories = split_arch_stories(mixed)
542
+ if len(mixed_stories) != 2:
543
+ fail("mixed H1+H2 same id should yield two blocks (H1-wins)")
544
+ if not mixed_stories[0].startswith("# US-0067"):
545
+ fail("mixed file first block should start at H1 US-0067")
546
+
547
+ # --- inner ## subheading inside # US- block is not a boundary ---
548
+ inner = "# US-0001: Story\n## Details\nnested\n# US-0002: Two\ny\n"
549
+ _, inner_stories = split_arch_stories(inner)
550
+ if len(inner_stories) != 2:
551
+ fail("inner ## Details must not create extra story boundary")
552
+ if "## Details" not in inner_stories[0]:
553
+ fail("inner subheading should remain inside first story block")
554
+
555
+ # --- BUG H1 parity ---
556
+ bug_arch = "# BUG-0009: Defect\nbody\n# US-0010: Story\nmore\n"
557
+ _, bug_stories = split_arch_stories(bug_arch)
558
+ if len(bug_stories) != 2 or not bug_stories[0].startswith("# BUG-0009"):
559
+ fail("BUG H1 story boundary not recognized")
560
+
561
+ # --- heading policy enforcement delta ---
562
+ baseline_text = "## US-0001: A\nx\n"
563
+ increased = baseline_text + "## US-0099: New H2\ny\n"
564
+ if check_arch_heading_policy(baseline_text, 1) is not None:
565
+ fail("stable H2 count should not fail policy")
566
+ if check_arch_heading_policy(increased, 1) != "ARCH_STORY_HEADING_LEVEL_INVALID":
567
+ fail("H2 count increase must return ARCH_STORY_HEADING_LEVEL_INVALID")
568
+ if check_arch_heading_policy(increased, 2) is not None:
569
+ fail("count decrease/normalization should pass policy")
570
+
571
+ with tempfile.TemporaryDirectory() as tmp:
572
+ root = Path(tmp)
573
+ (root / ".cursor").mkdir(parents=True, exist_ok=True)
574
+ (root / STATE_REL).parent.mkdir(parents=True, exist_ok=True)
575
+ (root / PO_REL).parent.mkdir(parents=True, exist_ok=True)
576
+ (root / PO_REL).write_text("## Stub\n\n", encoding="utf-8")
577
+ (root / ARCH_REL).write_text("# Architecture\n\n## Overview\nstub\n", encoding="utf-8")
578
+ policy = dict(DEFAULTS)
579
+ policy["STATE_HOT_MAX_LINES"] = "50"
580
+ policy["STATE_HOT_MAX_CHECKPOINTS"] = "2"
581
+ big_state = (
582
+ "## Active context surface\npreamble\n\n"
583
+ + "".join(f"## Checkpoint {i}\nbody {i}\n\n" for i in range(5))
584
+ )
585
+ (root / STATE_REL).write_text(big_state, encoding="utf-8")
586
+ v1 = rollover_state(root, policy, dry_run=False)
587
+ if not v1 or v1.get("moved", 0) < 1:
588
+ fail("state rollover should move at least one checkpoint")
589
+ v2 = rollover_state(root, policy, dry_run=False)
590
+ if v2 is not None:
591
+ fail("state rollover should be idempotent when within caps")
592
+ err = run_check(root, policy)
593
+ if err:
594
+ fail(f"state fixture should pass after rollover: {err}")
595
+
596
+ policy_po = dict(DEFAULTS)
597
+ policy_po["PO_TO_TL_HOT_MAX_LINES"] = "10"
598
+ policy_po["PO_TO_TL_HOT_MAX_SECTIONS"] = "2"
599
+ po_big = "".join(f"## S{i}\nL{i}\n\n" for i in range(5))
600
+ (root / PO_REL).write_text(po_big, encoding="utf-8")
601
+ r1 = rollover_po_to_tl(root, policy_po, dry_run=False)
602
+ if not r1:
603
+ fail("po_to_tl rollover expected")
604
+ r2 = rollover_po_to_tl(root, policy_po, dry_run=False)
605
+ if r2 is not None:
606
+ fail("po_to_tl idempotent")
607
+ if run_check(root, policy_po):
608
+ fail("po_to_tl check should pass")
609
+
610
+ policy_arch = dict(DEFAULTS)
611
+ policy_arch["ARCH_HOT_MAX_LINES"] = "12"
612
+ policy_arch["ARCH_HOT_MAX_STORY_SECTIONS"] = "2"
613
+ arch_big = "# Top\n\n" + "".join(f"# US-100{i}: X\nL\n\n" for i in range(5))
614
+ (root / ARCH_REL).write_text(arch_big, encoding="utf-8")
615
+ a1 = rollover_architecture(root, policy_arch, dry_run=False)
616
+ if not a1:
617
+ fail("architecture H1 rollover expected")
618
+ a2 = rollover_architecture(root, policy_arch, dry_run=False)
619
+ if a2 is not None:
620
+ fail("architecture H1 idempotent")
621
+ merged_arch = dict(DEFAULTS)
622
+ merged_arch.update(policy_arch)
623
+ if run_check(root, merged_arch):
624
+ fail("architecture H1 check should pass")
625
+
626
+ # --- ##-only rollover (legacy H2 sections) ---
627
+ policy_h2 = dict(DEFAULTS)
628
+ policy_h2["ARCH_HOT_MAX_LINES"] = "12"
629
+ policy_h2["ARCH_HOT_MAX_STORY_SECTIONS"] = "2"
630
+ h2_big = "# Top\n\n" + "".join(
631
+ f"## US-100{i}: Legacy\nL{i}\n\n" for i in range(4)
632
+ )
633
+ (root / ARCH_REL).write_text(h2_big, encoding="utf-8")
634
+ h2_1 = rollover_architecture(root, policy_h2, dry_run=False)
635
+ if not h2_1 or h2_1.get("moved", 0) < 1:
636
+ fail("##-only architecture rollover should move at least one block")
637
+ h2_2 = rollover_architecture(root, policy_h2, dry_run=False)
638
+ if h2_2 is not None:
639
+ fail("##-only architecture rollover should be idempotent")
640
+ merged_h2 = dict(DEFAULTS)
641
+ merged_h2.update(policy_h2)
642
+ if run_check(root, merged_h2):
643
+ fail("##-only architecture check should pass after rollover")
644
+
645
+ if errors:
646
+ for e in errors:
647
+ print(e, file=sys.stderr)
648
+ return 1
649
+ return 0
650
+
651
+
652
+ def main(argv: Optional[Sequence[str]] = None) -> int:
653
+ p = argparse.ArgumentParser(description="Triad hot-surface enforcement (DEC-0054).")
654
+ p.add_argument("--repo", help="repository root (default: parent of scripts/)")
655
+ mx = p.add_mutually_exclusive_group(required=True)
656
+ mx.add_argument("--check", action="store_true", help="verify caps, no writes")
657
+ mx.add_argument("--rollover", action="store_true", help="archive oldest units when over cap")
658
+ mx.add_argument("--self-test", action="store_true", help="internal regression fixtures")
659
+ mx.add_argument(
660
+ "--check-arch-heading-policy",
661
+ action="store_true",
662
+ help="fail when H2 story-heading count increased vs baseline",
663
+ )
664
+ p.add_argument(
665
+ "--baseline-h2-count",
666
+ type=int,
667
+ help="baseline H2 story-heading count (required with --check-arch-heading-policy)",
668
+ )
669
+ p.add_argument(
670
+ "--json",
671
+ action="store_true",
672
+ help="emit verification tuples as JSON lines (stderr for human mode)",
673
+ )
674
+ p.add_argument(
675
+ "--dry-run",
676
+ action="store_true",
677
+ help="with --rollover, compute moves without writing",
678
+ )
679
+ args = p.parse_args(argv)
680
+
681
+ if args.self_test:
682
+ return cmd_self_test()
683
+
684
+ repo = _repo_root(args.repo)
685
+ try:
686
+ policy = load_merged_policy(repo)
687
+ except OSError as exc:
688
+ print(
689
+ f"STATE_ARCHIVE_WRITE_FAILED could_not_read_scratchpad detail={exc}",
690
+ file=sys.stderr,
691
+ )
692
+ return 2
693
+
694
+ if args.check:
695
+ try:
696
+ errs = run_check(repo, policy)
697
+ except PolicyError as exc:
698
+ print(f"{exc.code} {exc.message}", file=sys.stderr)
699
+ return 2
700
+ if errs:
701
+ for e in errs:
702
+ print(e, file=sys.stderr)
703
+ return 1
704
+ return 0
705
+
706
+ if args.check_arch_heading_policy:
707
+ if args.baseline_h2_count is None:
708
+ print(
709
+ "STATE_ARCHIVE_VERIFICATION_FAILED "
710
+ "missing --baseline-h2-count for --check-arch-heading-policy",
711
+ file=sys.stderr,
712
+ )
713
+ return 2
714
+ try:
715
+ arch_text = (repo / ARCH_REL).read_text(encoding="utf-8")
716
+ except OSError as exc:
717
+ print(
718
+ f"STATE_ARCHIVE_WRITE_FAILED could_not_read_architecture detail={exc}",
719
+ file=sys.stderr,
720
+ )
721
+ return 2
722
+ code = check_arch_heading_policy(arch_text, args.baseline_h2_count)
723
+ if code:
724
+ print(
725
+ f"{code} h2_story_heading_count_increased "
726
+ f"baseline={args.baseline_h2_count} "
727
+ f"after={count_h2_story_headings(arch_text)}",
728
+ file=sys.stderr,
729
+ )
730
+ return 1
731
+ return 0
732
+
733
+ if args.rollover:
734
+ try:
735
+ outs = run_rollover_all(repo, policy, dry_run=args.dry_run)
736
+ except PolicyError as exc:
737
+ print(f"{exc.code} {exc.message}", file=sys.stderr)
738
+ return 2
739
+ if args.json:
740
+ for row in outs:
741
+ print(json.dumps(row, sort_keys=True, separators=(",", ":")))
742
+ elif outs:
743
+ print(
744
+ "rollover_complete units=" + ",".join(str(x.get("moved", 0)) for x in outs),
745
+ file=sys.stderr,
746
+ )
747
+ return 0
748
+
749
+ return 2
750
+
751
+
752
+ if __name__ == "__main__":
753
+ raise SystemExit(main())