@caddis/cli 0.4.52 → 0.4.54

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 (111) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  5. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
  12. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  13. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  14. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
  16. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
  19. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  22. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  23. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  25. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
  27. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  29. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  30. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  31. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  32. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  33. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  34. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  35. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  36. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  37. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  38. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  39. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  40. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  41. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  42. package/bundles/antigravity-plugin/skills/_registry.md +4 -6
  43. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  44. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  45. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  46. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
  47. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  49. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
  50. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  51. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  52. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  55. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
  56. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  57. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  58. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  59. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
  60. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  61. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  62. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
  64. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  65. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  66. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  67. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  68. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  69. package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
  70. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
  71. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
  72. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  73. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  74. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  76. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  77. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  78. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  79. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  80. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  81. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  82. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  83. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  84. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  85. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
  86. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  87. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  88. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  89. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  90. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  91. package/bundles/codex/AGENTS.md +5 -4
  92. package/bundles/manifest.json +3 -3
  93. package/package.json +1 -1
  94. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  95. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  96. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  97. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  98. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  99. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  100. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  101. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  102. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  103. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  104. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  105. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  106. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  107. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  108. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  110. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  111. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -0,0 +1,2109 @@
1
+ """Read a roadmap from the default branch on origin, pick the next batch, and change the roadmap only by docs PRs.
2
+
3
+ The roadmap file `.caddis/roadmaps/<slug>/roadmap.md` on `origin/<default>` is the only truth. Every command runs
4
+ `git fetch origin` first and reads the roadmap and plans with `git show origin/<default>:<path>`; it never reads the
5
+ working tree. A phase ref `p-alpha#2` is done only when its Tracker Status cell in the origin copy of
6
+ `.caddis/plans/p-alpha.md` (or `.caddis/plans/done/p-alpha.md`) starts with `done` or a check mark.
7
+
8
+ Rules the runner trusts unattended:
9
+ - a malformed roadmap fails closed: every command but `check` refuses (exit 4) and prints the parse errors;
10
+ - only `rollup` writes Status `done`, and only with every phase merged and no open blocker but `data-condition`;
11
+ - `rollup` also needs every `Depends on` milestone `done` or `not-needed`;
12
+ - `next` skips a milestone whose first not-done phase is held by another live runner lock (`<plan>#<n>` from a
13
+ roadmap runner, `<plan>#*` from a plan-mode runner; `<git common dir>/caddis-runner/<roadmap|plan>-<slug>/runner.lock`, and persistent claims in
14
+ `<git common dir>/caddis-runner/claims/`);
15
+ - a calendar blocker's Check is an ISO date `YYYY-MM-DD`; `recheck` clears it once the date has passed (UTC) and
16
+ never runs it; a calendar row whose Check is `-` stays until it is unblocked;
17
+ - `set` Status `not-needed` needs `--reason "dropped: <why>"` or `--reason .caddis/parking-lot/<file>.md` (on
18
+ origin); the reason lands as `- <id> not-needed: <reason> (<date>)` under `## Notes` in the same PR;
19
+ - `status` adds `old blockers: <id> <type> (<N> days)` for a blocker row older than 14 days; the age is the date of
20
+ the newest commit on origin that added that exact row (`git log -S`), so the roadmap never holds it;
21
+ - owner-only changes (`set` Status `not-needed`, any `set` Owner, `unblock` of an `owner-gate`, `block --check`) need
22
+ `--by-owner` and are refused headless: CADDIS_HEADLESS or CLAUDECODE in the
23
+ environment (any value, even empty), stdin not a terminal, or (Windows) stdin not a console (a NUL stdin is not);
24
+ - Exit check and Check commands run in a temporary worktree of `origin/<default>` (the merged code), with no stdin,
25
+ in their own process group (a job object on Windows); the tree is killed on every exit;
26
+ - every network call has a timeout (git fetch/ls-remote/push 120 s, gh 60 s, the merge chain 1800 s) and runs with
27
+ GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never; a timeout is exit 3 (a merge-chain timeout is logged as
28
+ "merge_exit": "timeout"); Ctrl+C kills the tree too;
29
+ - `## Milestones` and `## Blockers` hold only blank lines, ONE table whose every line starts with `|`, and prose with
30
+ no unescaped `|` set apart from the table by a blank line; any other line is a parse error (exit 4).
31
+ - a roadmap is a whitelist: ATX headings, plain paragraphs, lists and pipe tables (with a strict delimiter row) only.
32
+ A code fence, an HTML comment or block, a setext heading, a control character, a lone CR, a line or paragraph
33
+ separator, non-ASCII whitespace or a zero-width character anywhere is a parse error (exit 4): GitHub would hide,
34
+ split or move lines this parser reads.
35
+
36
+ Writes (`set`, `block`, `unblock`, `rollup`, `recheck`) take `<git common dir>/caddis-roadmaps/<slug>.write.lock`,
37
+ commit on a `roadmap/<slug>-<id>-<utc>` branch made from `origin/<default>` in a temp worktree, push, open a PR with
38
+ `gh api` (the account comes from GH_TOKEN when set) and hand it to `caddis_merge_chain.py --docs-only`. `--dry-run`
39
+ prints the diff it would write and the commands it would run, and runs and writes nothing.
40
+
41
+ Exit codes (caddis_exit): 0 clean, 1 blocked (nothing ready, check failing, PR not merged), 3 could not run
42
+ (no origin, no roadmap, lock busy), 4 error (malformed roadmap, unknown id, column or value, refused change).
43
+
44
+ Usage: caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
45
+ caddis_roadmap.py next --roadmap SLUG [--root DIR] [--json]
46
+ caddis_roadmap.py check --roadmap SLUG [--root DIR]
47
+ caddis_roadmap.py set --roadmap SLUG [--root DIR] --id ID --col Status|Owner|Target --value V [--by-owner]
48
+ [--reason "dropped: WHY" | --reason .caddis/parking-lot/FILE.md] [--dry-run]
49
+ caddis_roadmap.py block --roadmap SLUG [--root DIR] --id ID --type TYPE --reason TEXT [--check CMD --by-owner]
50
+ [--dry-run]
51
+ caddis_roadmap.py unblock --roadmap SLUG [--root DIR] --id ID --type TYPE --reason TEXT [--check CMD]
52
+ [--by-owner] [--dry-run]
53
+ caddis_roadmap.py rollup --roadmap SLUG [--root DIR] --id ID [--dry-run]
54
+ caddis_roadmap.py recheck --roadmap SLUG [--root DIR] [--dry-run]
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import contextlib
61
+ import datetime
62
+ import difflib
63
+ import json
64
+ import os
65
+ import re
66
+ import shutil
67
+ import signal
68
+ import socket
69
+ import subprocess
70
+ import sys
71
+ import tempfile
72
+ import threading
73
+ import time
74
+ import unicodedata
75
+ import uuid
76
+ from dataclasses import dataclass, field
77
+ from pathlib import Path
78
+
79
+ try:
80
+ import caddis_exit
81
+ import caddis_rows
82
+ except ModuleNotFoundError as exc:
83
+ if exc.name not in {"caddis_exit", "caddis_rows"}:
84
+ raise
85
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
86
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
87
+ import caddis_exit
88
+ import caddis_rows
89
+
90
+ SCRIPTS_DIR = Path(__file__).resolve().parent
91
+ STATUSES = ("todo", "in-progress", "code-done/data-pending", "done", "not-needed")
92
+ FINISHED = ("done", "not-needed")
93
+ OPEN = ("todo", "in-progress")
94
+ BLOCKER_TYPES = ("owner-gate", "external", "shared", "calendar", "data-condition")
95
+ SETTABLE = ("Status", "Owner", "Target")
96
+ MILESTONE_COLS = ["ID", "Milestone", "Phases", "Depends on", "Exit check", "Status", "Owner", "Target"]
97
+ BLOCKER_COLS = ["Milestone", "Type", "Reason", "Check"]
98
+ HEADLESS_VARS = ("CADDIS_HEADLESS", "CLAUDECODE")
99
+ EXIT_CHECK_TIMEOUT = 300
100
+ RECHECK_TIMEOUT = 120
101
+ LOCK_TIMEOUT = 30
102
+ LOCK_MALFORMED_STALE_S = 60 # an empty or non-JSON lock file older than this is stale
103
+ _WINDOWS = os.name == "nt" # a create can fail with PermissionError while a deleted lock's delete is pending
104
+ OUTPUT_TAIL_BYTES = 64 * 1024
105
+ OUTPUT_FILE_CAP = 1024 * 1024 # a check's on-disk output never grows past this
106
+ GIT_NET_TIMEOUT = 120
107
+ GH_TIMEOUT = 60
108
+ MERGE_CHAIN_TIMEOUT = 1800
109
+ GIT_NETWORK_CMDS = ("fetch", "ls-remote", "push")
110
+ NO_PROMPT_ENV = {"GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
111
+ WINDOWS = os.name == "nt"
112
+ SLUG_RE_TEXT = r"^[a-z0-9][a-z0-9-]{0,62}$" # THE roadmap slug rule; roadmap-run.ps1 and roadmap-recheck.ps1 use it
113
+ SLUG_RE = re.compile(SLUG_RE_TEXT)
114
+ ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
115
+ REF_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+)$")
116
+ PIPE_RE = re.compile(r"(?<!\\)\|") # an unescaped pipe
117
+ HOST_RE = re.compile(r"^Runner host:\s*(.*?)\s*$")
118
+ HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$") # [ \t], never \s: NBSP is no space
119
+ FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
120
+ DEP_CITE_RE = re.compile(r"\(dep[^)]*:\s*[A-Za-z]+-?\d+\)", re.IGNORECASE)
121
+ GH_URL_RE = re.compile(r"github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$")
122
+ ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # a calendar blocker's Check
123
+ PARKING_RE = re.compile(r"^\.caddis/parking-lot/[A-Za-z0-9._-]+\.md$") # a not-needed reason that names a parked item
124
+ DROPPED_PREFIX = "dropped:"
125
+ OLD_BLOCKER_DAYS = 14 # status lists a blocker whose row is older than this
126
+
127
+
128
+ class RoadmapError(Exception):
129
+ """A refused or impossible request; carries the caddis exit code."""
130
+
131
+ def __init__(self, message: str, code: int = caddis_exit.ERROR):
132
+ super().__init__(message)
133
+ self.code = code
134
+
135
+
136
+ # -- git -------------------------------------------------------------------------------------------------------
137
+
138
+
139
+ def _quiet_env() -> dict:
140
+ """The environment for git, gh and the merge chain: never wait for a credential prompt."""
141
+ env = dict(os.environ)
142
+ env.update(NO_PROMPT_ENV)
143
+ return env
144
+
145
+
146
+ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
147
+ argv = ["git", "-C", str(root), *args]
148
+ if args and args[0] in GIT_NETWORK_CMDS:
149
+ proc = run_bounded(argv, GIT_NET_TIMEOUT, f"git {args[0]}")
150
+ else:
151
+ proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
152
+ stdin=subprocess.DEVNULL, env=_quiet_env())
153
+ if check and proc.returncode != 0:
154
+ raise RoadmapError(f"git {' '.join(args)} failed: {(proc.stderr or proc.stdout).strip()[:300]}",
155
+ caddis_exit.NOT_RUN)
156
+ return proc
157
+
158
+
159
+ def default_branch(root: Path) -> str:
160
+ """origin/HEAD's branch; when it is not set locally, ask the remote (`git ls-remote --symref origin HEAD`)."""
161
+ proc = _git(root, "symbolic-ref", "refs/remotes/origin/HEAD", check=False)
162
+ ref = proc.stdout.strip()
163
+ if proc.returncode == 0 and ref.startswith("refs/remotes/origin/"):
164
+ return ref[len("refs/remotes/origin/"):]
165
+ remote = _git(root, "ls-remote", "--symref", "origin", "HEAD", check=False)
166
+ for line in remote.stdout.splitlines():
167
+ match = re.match(r"^ref:\s*refs/heads/(\S+)\s+HEAD$", line)
168
+ if match:
169
+ return match.group(1)
170
+ raise RoadmapError("cannot tell origin's default branch: origin/HEAD is not set and the remote did not say",
171
+ caddis_exit.NOT_RUN)
172
+
173
+
174
+ def fetch(root: Path) -> str:
175
+ """Fetch origin and return the default branch name."""
176
+ _git(root, "fetch", "--quiet", "origin")
177
+ return default_branch(root)
178
+
179
+
180
+ def show(root: Path, branch: str, path: str) -> str | None:
181
+ proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
182
+ stdin=subprocess.DEVNULL)
183
+ if proc.returncode != 0:
184
+ return None
185
+ # Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
186
+ return proc.stdout.decode("utf-8", errors="replace")
187
+
188
+
189
+ def gh_repo_of(root: Path) -> str | None:
190
+ """OWNER/NAME from `git remote get-url origin`, or None when origin is not a GitHub URL."""
191
+ url = _git(root, "remote", "get-url", "origin", check=False).stdout.strip()
192
+ match = GH_URL_RE.search(url)
193
+ return f"{match.group(1)}/{match.group(2)}" if match else None
194
+
195
+
196
+ def roadmap_path(slug: str) -> str:
197
+ return f".caddis/roadmaps/{slug}/roadmap.md"
198
+
199
+
200
+ def local_dir(root: Path, slug: str) -> Path:
201
+ return root / ".caddis" / "roadmaps" / slug / ".local"
202
+
203
+
204
+ def lock_path(root: Path, slug: str) -> Path:
205
+ """The write lock lives in the git common dir, so every worktree of one repo shares it."""
206
+ common = Path(_git(root, "rev-parse", "--git-common-dir").stdout.strip())
207
+ if not common.is_absolute():
208
+ common = Path(root) / common
209
+ return common.resolve() / "caddis-roadmaps" / f"{slug}.write.lock"
210
+
211
+
212
+ # -- parsing ---------------------------------------------------------------------------------------------------
213
+
214
+
215
+ def _mask_fences(lines: list[str]) -> list[str]:
216
+ """Blank every line of a fenced code block (fence lines included), keeping line numbers.
217
+
218
+ CommonMark: a fence opens with 3+ backticks or tildes (up to 3 spaces of indent); it closes only on a line of the
219
+ same character, at least as long, with nothing else on it. A line with an info string never closes it.
220
+ """
221
+ out: list[str] = []
222
+ fence: str | None = None
223
+ for line in lines:
224
+ if fence is None:
225
+ match = FENCE_OPEN_RE.match(line)
226
+ if match and not (match.group(1)[0] == "`" and "`" in match.group(2)):
227
+ fence = match.group(1)
228
+ out.append("")
229
+ else:
230
+ out.append(line)
231
+ continue
232
+ stripped = line.strip()
233
+ # A tab before the fence is 4 columns of indent in CommonMark, so it never closes a fence.
234
+ if (len(line) - len(line.lstrip(" ")) <= 3 and line.lstrip(" ").startswith(fence[0])
235
+ and len(stripped) >= len(fence)
236
+ and set(stripped) == {fence[0]}):
237
+ fence = None
238
+ out.append("")
239
+ return out
240
+
241
+
242
+ def _is_separator(cells: list[str]) -> bool:
243
+ return bool(cells) and all(c and set(c) <= set("-: ") and "-" in c for c in cells)
244
+
245
+
246
+ @dataclass
247
+ class Table:
248
+ heading: str # the heading line, stripped ("" when none)
249
+ header_idx: int
250
+ rows: list[int]
251
+ columns: list[str]
252
+
253
+
254
+ def _is_strict_delimiter(header: list[str], cells: list[str]) -> bool:
255
+ """A GFM delimiter row: every cell is `:?-+:?`, and there are as many cells as in the header row."""
256
+ return bool(cells) and len(cells) == len(header) and all(STRICT_DELIM_CELL_RE.match(c) for c in cells)
257
+
258
+
259
+ def _scan(text: str) -> tuple[list[str], list[str], list[Table], list[str]]:
260
+ """(lines, masked lines, tables, layout errors) for a plan: fenced code is skipped."""
261
+ lines = text.splitlines()
262
+ return _tables(lines, _mask_fences(lines), strict=False)
263
+
264
+
265
+ def _tables(lines: list[str], masked: list[str], strict: bool) -> tuple[list[str], list[str], list[Table], list[str]]:
266
+ """Pipe lines that do not form a table are errors. `strict` (a roadmap) also needs a GFM delimiter row."""
267
+ tables: list[Table] = []
268
+ errors: list[str] = []
269
+ heading = ""
270
+ i, n = 0, len(masked)
271
+ while i < n:
272
+ line = masked[i]
273
+ if HEADING_RE.match(line):
274
+ heading = line.strip()
275
+ if line.lstrip().startswith("|") and not line.startswith("|"):
276
+ errors.append(f"line {i + 1}: indented table row `{line.strip()[:60]}`")
277
+ i += 1
278
+ continue
279
+ if not line.startswith("|"):
280
+ i += 1
281
+ continue
282
+ start = i
283
+ i += 1
284
+ # A table is the run of lines that start with `|`. A line after it without a leading pipe is not a row; in
285
+ # `## Milestones` and `## Blockers` it is a parse error (see _strict_sections).
286
+ while i < n and masked[i].startswith("|"):
287
+ i += 1
288
+ delim = caddis_rows.split_cells(masked[start + 1]) if i - start >= 2 else []
289
+ is_table = (_is_strict_delimiter(caddis_rows.split_cells(masked[start]), delim) if strict
290
+ else _is_separator(delim))
291
+ if is_table:
292
+ tables.append(Table(heading, start, list(range(start + 2, i)), caddis_rows.split_cells(masked[start])))
293
+ else:
294
+ for idx in range(start, i):
295
+ errors.append(f"line {idx + 1}: table row outside a table (no header and separator above it): "
296
+ f"`{masked[idx].strip()[:60]}`")
297
+ return lines, masked, tables, errors
298
+
299
+
300
+ STRICT_SECTIONS = ("## Milestones", "## Blockers")
301
+ STRICT_DELIM_CELL_RE = re.compile(r"^[ \t]*:?-+:?[ \t]*$")
302
+ HTML_TABLE_RE = re.compile(r"^\s*<(table|tr)\b", re.IGNORECASE)
303
+
304
+
305
+ def _strict_sections(masked: list[str]) -> list[str]:
306
+ """Errors for `## Milestones` and `## Blockers` (each until the next heading of level 1 or 2).
307
+
308
+ Allowed: blank lines, ONE table whose every line starts with `|`, and prose with no unescaped `|`, set apart
309
+ from the table by a blank line. Anything else could hide a row from this parser and fails closed. Fenced code
310
+ is already blanked in `masked` (a roadmap has none: it is refused).
311
+ """
312
+ errors: list[str] = []
313
+ section: str | None = None
314
+ in_table = table_seen = False
315
+ prev_blank = True
316
+ for i, line in enumerate(masked):
317
+ heading = HEADING_RE.match(line)
318
+ if heading and len(heading.group(1)) <= 2:
319
+ section = line.strip() if line.strip() in STRICT_SECTIONS else None
320
+ in_table = table_seen = False
321
+ prev_blank = True
322
+ continue
323
+ if section is None:
324
+ continue
325
+ where = f"line {i + 1}: in `{section}`"
326
+ if not line.strip():
327
+ in_table, prev_blank = False, True
328
+ continue
329
+ if line.startswith("|"):
330
+ if not in_table:
331
+ if table_seen:
332
+ errors.append(f"{where}: a second table; a section holds exactly one table")
333
+ elif not prev_blank:
334
+ errors.append(f"{where}: the table starts directly after a prose line; put a blank line between")
335
+ table_seen = in_table = True
336
+ elif in_table:
337
+ errors.append(f"{where}: a line directly after the table without a blank line (a row must start with "
338
+ f"`|`; prose needs a blank line before it): `{line.strip()[:60]}`")
339
+ elif HTML_TABLE_RE.match(line):
340
+ errors.append(f"{where}: an HTML table line; use a pipe table: `{line.strip()[:60]}`")
341
+ elif PIPE_RE.search(line):
342
+ errors.append(f"{where}: a `|` outside the table (a headerless table or prose with a pipe); "
343
+ f"escape it as `\\|`: `{line.strip()[:60]}`")
344
+ prev_blank = False
345
+ return errors
346
+
347
+
348
+ def _cell_text(cell: str) -> str:
349
+ """A cell's text: `\\|` unescaped, one pair of wrapping backticks removed, `-` means empty."""
350
+ value = cell.replace("\\|", "|").strip()
351
+ if len(value) >= 2 and value.startswith("`") and value.endswith("`") and value.count("`") == 2:
352
+ value = value[1:-1].strip()
353
+ return "" if value == "-" else value
354
+
355
+
356
+ def _list_cell(cell: str) -> list[str]:
357
+ value = _cell_text(cell)
358
+ return [item.strip() for item in value.split(",") if item.strip()] if value else []
359
+
360
+
361
+ @dataclass
362
+ class Milestone:
363
+ id: str
364
+ name: str
365
+ phases: list[str]
366
+ depends: list[str]
367
+ exit_check: str
368
+ status: str
369
+ owner: str
370
+ target: str
371
+ line: int
372
+
373
+
374
+ @dataclass
375
+ class Blocker:
376
+ milestone: str
377
+ type: str
378
+ reason: str
379
+ check: str
380
+ line: int
381
+
382
+ @property
383
+ def key(self) -> tuple[str, str, str, str]:
384
+ return (self.milestone, self.type, self.reason, self.check)
385
+
386
+
387
+ @dataclass
388
+ class Roadmap:
389
+ runner_host: str = ""
390
+ milestones: list[Milestone] = field(default_factory=list)
391
+ blockers: list[Blocker] = field(default_factory=list)
392
+ errors: list[str] = field(default_factory=list)
393
+ blocker_table: Table | None = None
394
+
395
+ def get(self, mid: str) -> Milestone | None:
396
+ return next((m for m in self.milestones if m.id == mid), None)
397
+
398
+ def blockers_of(self, mid: str) -> list[Blocker]:
399
+ return [b for b in self.blockers if b.milestone == mid]
400
+
401
+
402
+ def _section(tables: list[Table], name: str, columns: list[str], errors: list[str]) -> Table | None:
403
+ """The one table under exactly `## <name>`; a near-miss heading or a second table is an error."""
404
+ exact = f"## {name}"
405
+ found: Table | None = None
406
+ for table in tables:
407
+ heading_text = HEADING_RE.match(table.heading).group(2) or "" if table.heading else ""
408
+ looks_like = heading_text.lower().startswith(name.lower().rstrip("s")) or table.columns == columns
409
+ if table.heading == exact:
410
+ if found is not None:
411
+ errors.append(f"line {table.header_idx + 1}: a second table under `{exact}`")
412
+ else:
413
+ found = table
414
+ elif looks_like:
415
+ errors.append(f"line {table.header_idx + 1}: a {name} table under `{table.heading or '(no heading)'}`; "
416
+ f"the heading must be exactly `{exact}`")
417
+ if found is None:
418
+ errors.append(f"no table under `{exact}`")
419
+ elif found.columns != columns:
420
+ errors.append(f"{name} columns are {found.columns}, want {columns}")
421
+ return None
422
+ return found
423
+
424
+
425
+ HTML_COMMENT_RE = re.compile(r"<!--|-->")
426
+ # An HTML block start: `<` then a letter, `/`, `!` or `?` (CommonMark block types 1-7), at any indent. An autolink
427
+ # (`<https://x>`, `<a@b.c>`) is not HTML and is allowed.
428
+ HTML_BLOCK_RE = re.compile(r"^[ \t]*<[A-Za-z/!?]")
429
+ AUTOLINK_RE = re.compile(r"^[ \t]*<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^ \t<>]*|[^ \t<>@]+@[^ \t<>]+)>")
430
+ SETEXT_RE = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$")
431
+ # A table delimiter row with no pipe (`:-`, `:-:`, `-:`) makes GitHub render a one-column table.
432
+ PIPELESS_DELIM_RE = re.compile(r"^[ \t]*(?::-+:?|-+:)[ \t]*$")
433
+ # A code fence opener at any indent, also inside a list item or a block quote (`- ```, `1. ~~~`, `> ````).
434
+ ANY_FENCE_RE = re.compile(r"^[ \t]*(?:(?:[-*+>]|\d{1,9}[.)])[ \t]*)*(?:```|~~~)")
435
+ # Characters that end or split a line for some reader but not for GitHub (or the reverse): C0 controls but tab and
436
+ # LF, DEL, NEL, U+2028 and U+2029. A CR is allowed only as part of CRLF (CRLF is made LF before this check).
437
+ BAD_CHAR_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x85\u2028\u2029\r]")
438
+ FRONTMATTER_FENCE_RE = re.compile(r"^---[ \t]*$")
439
+
440
+
441
+ def _frontmatter_end(lines: list[str]) -> int:
442
+ """The index of the `---` line that closes a YAML frontmatter block opened on line 1, or -1 when there is none."""
443
+ if not lines or not FRONTMATTER_FENCE_RE.match(lines[0]):
444
+ return -1
445
+ return next((i for i in range(1, len(lines)) if FRONTMATTER_FENCE_RE.match(lines[i])), -1)
446
+
447
+
448
+ def _char_name(ch: str) -> str:
449
+ name = unicodedata.name(ch, "")
450
+ return f"U+{ord(ch):04X}" + (f" {name}" if name else "")
451
+
452
+
453
+ def _roadmap_lines(text: str) -> tuple[list[str], int, list[str]]:
454
+ """(lines, frontmatter end index, errors). One BOM at the start is dropped, CRLF becomes LF, and the text is split
455
+ on LF only: never splitlines(), which also splits on form feed, NEL, U+2028 and others where GitHub does not.
456
+
457
+ Refused anywhere: BAD_CHAR_RE. Refused outside the frontmatter: non-ASCII whitespace (category Zs) and format
458
+ characters (category Cf: a zero-width space, a BOM that is not at the start, bidi marks).
459
+ """
460
+ if text.startswith("\ufeff"):
461
+ text = text[1:]
462
+ lines = text.replace("\r\n", "\n").split("\n")
463
+ front_end = _frontmatter_end(lines)
464
+ errors: list[str] = []
465
+ for i, line in enumerate(lines):
466
+ bad = set(BAD_CHAR_RE.findall(line))
467
+ if i > front_end:
468
+ bad |= {ch for ch in line if ord(ch) > 0x7F and unicodedata.category(ch) in ("Zs", "Cf")}
469
+ if bad:
470
+ names = ", ".join(_char_name(ch) for ch in sorted(bad))
471
+ errors.append(f"line {i + 1}: a control, line-separator, non-ASCII space or zero-width character "
472
+ f"({names}); a roadmap allows plain text only")
473
+ return lines, front_end, errors
474
+
475
+
476
+ def _hidden_markup(lines: list[str], front_end: int) -> list[str]:
477
+ """Errors for markup GitHub renders differently from this parser. A roadmap is a whitelist, so each fails closed.
478
+
479
+ A code fence (also one in a list item or a block quote) hides lines on GitHub, and where it opens and closes is
480
+ easy to make disagree, so a roadmap has none. An HTML comment or block hides lines on GitHub that this parser
481
+ reads. A setext heading (a text line with a `===` or `---` line under it) is a heading on GitHub but not here,
482
+ so a table after it would render outside the section this parser puts it in.
483
+ """
484
+ errors: list[str] = []
485
+ for i, line in enumerate(lines):
486
+ where = f"line {i + 1}"
487
+ if ANY_FENCE_RE.match(line):
488
+ errors.append(f"{where}: a code fence (``` or ~~~); a roadmap allows no code fences, use inline code: "
489
+ f"`{line.strip()[:60]}`")
490
+ elif HTML_COMMENT_RE.search(line):
491
+ errors.append(f"{where}: an HTML comment (`<!--` or `-->`) can hide lines on GitHub; write it as plain "
492
+ f"prose: `{line.strip()[:60]}`")
493
+ elif HTML_BLOCK_RE.match(line) and not AUTOLINK_RE.match(line):
494
+ errors.append(f"{where}: an HTML block line can hide lines on GitHub; use Markdown: "
495
+ f"`{line.strip()[:60]}`")
496
+ elif 0 < i < front_end and "|" in line:
497
+ errors.append(f"{where}: a `|` in the frontmatter; GitHub may render a frontmatter it cannot read as "
498
+ f"Markdown, so a roadmap's frontmatter holds no table characters")
499
+ elif i > front_end and PIPELESS_DELIM_RE.match(line):
500
+ errors.append(f"{where}: `{line.strip()}` is a table delimiter row with no pipe, so GitHub renders a "
501
+ f"one-column table here; remove it")
502
+ elif (i > front_end and i > 0 and SETEXT_RE.match(line) and lines[i - 1].strip()
503
+ and not lines[i - 1].startswith("|") and not HEADING_RE.match(lines[i - 1])):
504
+ errors.append(f"{where}: a setext heading underline under `{lines[i - 1].strip()[:40]}`; use an ATX "
505
+ f"heading (`## ...`), or put a blank line above a `---` break")
506
+ return errors
507
+
508
+
509
+ def parse_roadmap(text: str) -> Roadmap:
510
+ """Parse a roadmap. Line numbers index `text` split on LF; the frontmatter is not read (GitHub shows it apart)."""
511
+ rm = Roadmap()
512
+ raw, front_end, char_errors = _roadmap_lines(text)
513
+ view = [""] * (front_end + 1) + raw[front_end + 1:]
514
+ lines, _view, tables, rm.errors = _tables(view, view, strict=True)
515
+ rm.errors[:0] = char_errors
516
+ rm.errors.extend(_hidden_markup(raw, front_end))
517
+ rm.errors.extend(_strict_sections(view))
518
+ rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
519
+ if not rm.runner_host:
520
+ rm.errors.append("no `Runner host: <host>` line")
521
+
522
+ table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
523
+ seen: set[str] = set()
524
+ for idx in table.rows if table else []:
525
+ cells = caddis_rows.split_cells(lines[idx])
526
+ if len(cells) != len(MILESTONE_COLS):
527
+ rm.errors.append(f"line {idx + 1}: {len(cells)} cells, want {len(MILESTONE_COLS)}")
528
+ continue
529
+ ms = Milestone(id=_cell_text(cells[0]), name=cells[1], phases=_list_cell(cells[2]),
530
+ depends=_list_cell(cells[3]), exit_check=_cell_text(cells[4]), status=_cell_text(cells[5]),
531
+ owner=_cell_text(cells[6]), target=_cell_text(cells[7]), line=idx)
532
+ if not ID_RE.match(ms.id):
533
+ rm.errors.append(f"line {idx + 1}: milestone id `{ms.id}` is not [A-Za-z0-9_-]+")
534
+ continue
535
+ if ms.id in seen:
536
+ rm.errors.append(f"line {idx + 1}: duplicate ID {ms.id}")
537
+ seen.add(ms.id)
538
+ if ms.status not in STATUSES:
539
+ rm.errors.append(f"{ms.id}: Status `{ms.status}` is not one of {', '.join(STATUSES)}")
540
+ for ref in ms.phases:
541
+ if not REF_RE.match(ref):
542
+ rm.errors.append(f"{ms.id}: phase ref `{ref}` is not <plan>#<phase>")
543
+ rm.milestones.append(ms)
544
+ for ms in rm.milestones:
545
+ for dep in ms.depends:
546
+ if dep not in seen:
547
+ rm.errors.append(f"{ms.id}: Depends on unknown milestone {dep}")
548
+
549
+ rm.blocker_table = _section(tables, "Blockers", BLOCKER_COLS, rm.errors)
550
+ for idx in rm.blocker_table.rows if rm.blocker_table else []:
551
+ cells = caddis_rows.split_cells(lines[idx])
552
+ if len(cells) != len(BLOCKER_COLS):
553
+ rm.errors.append(f"line {idx + 1}: {len(cells)} cells, want {len(BLOCKER_COLS)}")
554
+ continue
555
+ b = Blocker(milestone=_cell_text(cells[0]), type=_cell_text(cells[1]),
556
+ reason=cells[2].replace("\\|", "|").strip(), check=_cell_text(cells[3]), line=idx)
557
+ if b.type not in BLOCKER_TYPES:
558
+ rm.errors.append(f"line {idx + 1}: blocker type `{b.type}` is not one of {', '.join(BLOCKER_TYPES)}")
559
+ if b.milestone not in seen:
560
+ rm.errors.append(f"line {idx + 1}: blocker on unknown milestone `{b.milestone}`")
561
+ rm.blockers.append(b)
562
+ return rm
563
+
564
+
565
+ def parse_tracker(text: str) -> dict[int, str]:
566
+ """Phase number -> Status cell, from the table under a `## Tracker...` heading."""
567
+ lines, _masked, tables, _errors = _scan(text)
568
+ for table in tables:
569
+ if "tracker" not in table.heading.lower():
570
+ continue
571
+ cols = [c.strip().lower() for c in table.columns]
572
+ if "phase" not in cols or "status" not in cols:
573
+ continue
574
+ p_idx, s_idx = cols.index("phase"), cols.index("status")
575
+ out: dict[int, str] = {}
576
+ for idx in table.rows:
577
+ cells = caddis_rows.split_cells(lines[idx])
578
+ if len(cells) <= max(p_idx, s_idx):
579
+ continue
580
+ num = re.sub(r"^(phase\s*|p)", "", cells[p_idx].strip(), flags=re.IGNORECASE)
581
+ if num.isdigit():
582
+ out[int(num)] = cells[s_idx].strip()
583
+ return out
584
+ return {}
585
+
586
+
587
+ def _is_done_status(cell: str) -> bool:
588
+ value = cell.strip().lstrip("*").strip()
589
+ return value.lower().startswith("done") or value.startswith("✅")
590
+
591
+
592
+ class PhaseIndex:
593
+ """Phase done-ness read from the origin copy of each plan's Tracker; cached per command."""
594
+
595
+ def __init__(self, root: Path, branch: str):
596
+ self.root, self.branch = root, branch
597
+ self._trackers: dict[str, dict[int, str] | None] = {}
598
+
599
+ def tracker(self, plan: str) -> dict[int, str] | None:
600
+ if plan not in self._trackers:
601
+ text = show(self.root, self.branch, f".caddis/plans/{plan}.md")
602
+ if text is None:
603
+ text = show(self.root, self.branch, f".caddis/plans/done/{plan}.md")
604
+ self._trackers[plan] = parse_tracker(text) if text is not None else None
605
+ return self._trackers[plan]
606
+
607
+ def state(self, ref: str) -> str:
608
+ """'done', 'open' or 'unknown'."""
609
+ match = REF_RE.match(ref)
610
+ if not match:
611
+ return "unknown"
612
+ rows = self.tracker(match.group(1))
613
+ if rows is None or int(match.group(2)) not in rows:
614
+ return "unknown"
615
+ return "done" if _is_done_status(rows[int(match.group(2))]) else "open"
616
+
617
+
618
+ # -- loading ----------------------------------------------------------------------------------------------------
619
+
620
+
621
+ @dataclass
622
+ class Snapshot:
623
+ root: Path
624
+ slug: str
625
+ branch: str
626
+ text: str
627
+ roadmap: Roadmap
628
+ phases: PhaseIndex
629
+ errors: list[str]
630
+
631
+
632
+ def load(root: Path, slug: str, strict: bool = True) -> Snapshot:
633
+ """Fetch and read the origin copy. strict: any parse error or unknown phase ref refuses (exit 4)."""
634
+ if not SLUG_RE.match(slug):
635
+ raise RoadmapError(f"bad roadmap slug `{slug}`")
636
+ root = Path(root)
637
+ branch = fetch(root)
638
+ text = show(root, branch, roadmap_path(slug))
639
+ if text is None:
640
+ raise RoadmapError(f"{roadmap_path(slug)} is not on origin/{branch}", caddis_exit.NOT_RUN)
641
+ rm = parse_roadmap(text)
642
+ phases = PhaseIndex(root, branch)
643
+ errors = list(rm.errors)
644
+ for ms in rm.milestones:
645
+ for ref in ms.phases:
646
+ if REF_RE.match(ref) and phases.state(ref) == "unknown":
647
+ errors.append(f"{ms.id}: unknown phase ref {ref} (no such plan or Tracker row on origin/{branch})")
648
+ if strict and errors:
649
+ raise RoadmapError(f"{roadmap_path(slug)} on origin/{branch} has {len(errors)} error(s); fix it by a PR:\n"
650
+ + "\n".join(f" - {e}" for e in errors))
651
+ return Snapshot(root, slug, branch, text, rm, phases, errors)
652
+
653
+
654
+ # -- reading commands ------------------------------------------------------------------------------------------
655
+
656
+
657
+ def runner_ineligible(rm: Roadmap, ms: Milestone) -> str | None:
658
+ """Why the runner may not act on this milestone (its phases or its rollup), or None. The ONE rule both `next` and
659
+ the runner's rollup sweep use: every Depends on milestone finished, no blocker, Owner the runner (or empty)."""
660
+ unmet = [d for d in ms.depends if rm.get(d) is None or rm.get(d).status not in FINISHED]
661
+ if unmet:
662
+ return "depends on " + ", ".join(f"{d} ({rm.get(d).status if rm.get(d) else 'unknown'})" for d in unmet)
663
+ blocks = rm.blockers_of(ms.id)
664
+ if blocks:
665
+ return "; ".join(f"blocked: {b.type} {b.reason}" for b in blocks)
666
+ if ms.owner not in ("", "runner"):
667
+ return f"owner {ms.owner} (not the runner)"
668
+ return None
669
+
670
+
671
+ def _select(snap: Snapshot, skip: dict | None = None,
672
+ held_out: list | None = None) -> tuple[dict | None, list[tuple[str, str]]]:
673
+ """The first ready milestone's first not-done phase, and why each milestone before it was skipped.
674
+
675
+ `skip` maps a batch (`<plan>#<n>`, or `<plan>#*` for a whole plan) to what holds it (a label, or a
676
+ live_runner_holders dict); any held batch of a plan holds every phase of that plan: a milestone whose first
677
+ not-done phase is in a held plan is skipped (never its later phases
678
+ instead: phases of a milestone run in order). `held_out`, when given, receives one dict per held skip
679
+ (`ref`, `kind`, `holder`) so callers never parse the reason text."""
680
+ skip = skip or {}
681
+ rm = snap.roadmap
682
+ reasons: list[tuple[str, str]] = []
683
+ for ms in rm.milestones:
684
+ if ms.status not in OPEN:
685
+ reasons.append((ms.id, f"status {ms.status}"))
686
+ continue
687
+ not_runners = runner_ineligible(rm, ms)
688
+ if not_runners:
689
+ reasons.append((ms.id, not_runners))
690
+ continue
691
+ states = [(ref, snap.phases.state(ref)) for ref in ms.phases]
692
+ unknown = [ref for ref, state in states if state == "unknown"]
693
+ if unknown:
694
+ reasons.append((ms.id, "unknown phase ref " + ", ".join(unknown)))
695
+ continue
696
+ ref = next((r for r, state in states if state == "open"), None)
697
+ if ref is None:
698
+ reasons.append((ms.id, "every phase merged; waits for rollup"))
699
+ continue
700
+ # A plan in flight anywhere holds the WHOLE plan: any held `<plan>#<n>` (or `<plan>#*`) holds every phase.
701
+ held = skip.get(ref) or skip.get(f"{match_ref(ref)}#*") or next(
702
+ (h for batch, h in skip.items() if match_ref(batch) == match_ref(ref)), None)
703
+ if held:
704
+ reasons.append((ms.id, f"phase {ref} is held by {holder_label(held)}"))
705
+ if held_out is not None:
706
+ held_out.append({"ref": ref, **(held if isinstance(held, dict) else {"kind": "lock", "holder": held})})
707
+ continue
708
+ match = REF_RE.match(ref)
709
+ assert match
710
+ return {"milestone": ms.id, "plan": match.group(1), "phase": int(match.group(2))}, reasons
711
+ return None, reasons
712
+
713
+
714
+ def match_ref(ref: str) -> str:
715
+ """The plan of a phase ref `<plan>#<n>`."""
716
+ return ref.split("#", 1)[0]
717
+
718
+
719
+ def next_with_snapshot(root: Path, slug: str) -> tuple[dict | None, Snapshot]:
720
+ """The next batch (as next_batch) and the origin snapshot it was chosen from (one fetch)."""
721
+ snap = load(Path(root), slug)
722
+ batch, _reasons = _select(snap, other_roadmap_batches(Path(root), slug))
723
+ return batch, snap
724
+
725
+
726
+ def next_batch(root: Path, slug: str) -> dict | None:
727
+ return next_with_snapshot(root, slug)[0]
728
+
729
+
730
+ def _utc_today() -> datetime.date:
731
+ return datetime.datetime.now(datetime.timezone.utc).date()
732
+
733
+
734
+ def iso_date(value: str) -> datetime.date | None:
735
+ """The date of a calendar Check (`YYYY-MM-DD`, backticks allowed), or None when it is not a real ISO date."""
736
+ text = (value or "").strip().strip("`").strip()
737
+ if not ISO_DATE_RE.match(text):
738
+ return None
739
+ try:
740
+ return datetime.date.fromisoformat(text)
741
+ except ValueError:
742
+ return None
743
+
744
+
745
+ def blocker_since(root: Path, branch: str, slug: str, row_line: str) -> datetime.date | None:
746
+ """The date of the newest commit on origin/<branch> that changed the count of this exact row line in the roadmap
747
+ (for a row that exists now: the commit that added it). None on any error. The roadmap text never holds the date."""
748
+ try:
749
+ proc = _git(Path(root), "log", "-1", "--format=%cs", "-S", row_line, f"origin/{branch}", "--",
750
+ roadmap_path(slug), check=False)
751
+ if proc.returncode != 0:
752
+ return None
753
+ return datetime.date.fromisoformat(proc.stdout.strip())
754
+ except Exception: # age is advice: it never breaks status
755
+ return None
756
+
757
+
758
+ def _old_blockers(snap: Snapshot, today: datetime.date) -> list[str]:
759
+ """`<id> <type> (<N> days)` for each blocker of an unfinished milestone older than OLD_BLOCKER_DAYS, oldest
760
+ first."""
761
+ rm = snap.roadmap
762
+ lines = snap.text.splitlines()
763
+ aged: list[tuple[int, int, str]] = []
764
+ for ms in rm.milestones:
765
+ if ms.status in FINISHED:
766
+ continue
767
+ for b in rm.blockers_of(ms.id):
768
+ if not 0 <= b.line < len(lines):
769
+ continue
770
+ since = blocker_since(snap.root, snap.branch, snap.slug, lines[b.line])
771
+ if since is None:
772
+ continue
773
+ days = (today - since).days
774
+ if days > OLD_BLOCKER_DAYS:
775
+ aged.append((-days, len(aged), f"{b.milestone} {b.type} ({days} day{'' if days == 1 else 's'})"))
776
+ return [text for _days, _order, text in sorted(aged)]
777
+
778
+
779
+ def status(root: Path, slug: str, today: datetime.date | None = None) -> dict:
780
+ snap = load(Path(root), slug)
781
+ rm = snap.roadmap
782
+ batch, _reasons = _select(snap, other_roadmap_batches(Path(root), slug))
783
+ milestones = []
784
+ for ms in rm.milestones:
785
+ milestones.append({
786
+ "id": ms.id, "status": ms.status, "owner": ms.owner or "runner", "target": ms.target,
787
+ "depends": ms.depends,
788
+ "phases": [{"ref": ref, "state": snap.phases.state(ref)} for ref in ms.phases],
789
+ "blockers": [{"type": b.type, "reason": b.reason, "check": b.check} for b in rm.blockers_of(ms.id)],
790
+ })
791
+ done = sum(1 for ms in rm.milestones if ms.status in FINISHED)
792
+ parts = [f"{done} of {len(rm.milestones)} done", f"next {batch['milestone'] if batch else 'none'}"]
793
+ for ms in rm.milestones:
794
+ if ms.status in FINISHED:
795
+ continue
796
+ parts.extend(f"{ms.id} waits for: {b.type} {b.reason}" for b in rm.blockers_of(ms.id))
797
+ old = _old_blockers(snap, today or _utc_today())
798
+ if old:
799
+ parts.append("old blockers: " + ", ".join(old))
800
+ return {"roadmap": slug, "branch": snap.branch, "runner_host": rm.runner_host, "done": done,
801
+ "total": len(rm.milestones), "next": batch, "milestones": milestones, "line": "; ".join(parts)}
802
+
803
+
804
+ def check(root: Path, slug: str) -> tuple[list[str], list[str]]:
805
+ """(errors, warnings) for the origin copy of the roadmap."""
806
+ snap = load(Path(root), slug, strict=False)
807
+ errors = list(snap.errors)
808
+ for ms in snap.roadmap.milestones:
809
+ if not ms.phases and not ms.exit_check:
810
+ errors.append(f"{ms.id}: no phase refs and no Exit check, so nothing could ever show it done")
811
+ return errors, _dep_warnings(snap)
812
+
813
+
814
+ def _dep_warnings(snap: Snapshot) -> list[str]:
815
+ """Warn when the last commit touching the roadmap changed a Depends on cell without citing a decision."""
816
+ path = roadmap_path(snap.slug)
817
+ last = _git(snap.root, "log", "-1", "--format=%H", f"origin/{snap.branch}", "--", path, check=False).stdout.strip()
818
+ if not last:
819
+ return []
820
+ before = _git(snap.root, "show", f"{last}~1:{path}", check=False)
821
+ if before.returncode != 0:
822
+ return []
823
+ old = {m.id: m.depends for m in parse_roadmap(before.stdout).milestones}
824
+ warnings = []
825
+ for ms in snap.roadmap.milestones:
826
+ if ms.id in old and old[ms.id] != ms.depends and not DEP_CITE_RE.search(ms.name):
827
+ warnings.append(f"{ms.id}: Depends on changed in {last[:12]} without a decision id in the Milestone "
828
+ f"cell, e.g. `{ms.id} (dep waived: D1)`")
829
+ return warnings
830
+
831
+
832
+ # -- edits (pure text -> text; line endings kept) --------------------------------------------------------------
833
+
834
+
835
+ def _eol(text: str) -> str:
836
+ return "\r\n" if "\r\n" in text else "\n"
837
+
838
+
839
+ def _clean_value(value: str, what: str) -> str:
840
+ value = value.strip()
841
+ # splitlines, not "\n": U+2028, U+2029, U+0085, \v and \f also end a line for a reader of the file.
842
+ if len(value.splitlines()) > 1:
843
+ raise RoadmapError(f"{what} must be one line")
844
+ return value.replace("|", "\\|")
845
+
846
+
847
+ def _replace_line(text: str, idx: int, new_line: str) -> str:
848
+ lines = text.splitlines(keepends=True)
849
+ body = lines[idx].rstrip("\r\n")
850
+ lines[idx] = new_line + lines[idx][len(body):]
851
+ return "".join(lines)
852
+
853
+
854
+ def edit_cell(text: str, mid: str, col: str, value: str) -> str:
855
+ rm = parse_roadmap(text)
856
+ ms = rm.get(mid)
857
+ if ms is None:
858
+ raise RoadmapError(f"unknown milestone id {mid}")
859
+ cells = caddis_rows.split_cells(text.splitlines()[ms.line])
860
+ cells[MILESTONE_COLS.index(col)] = _clean_value(value, col) or "-"
861
+ return _replace_line(text, ms.line, "| " + " | ".join(cells) + " |")
862
+
863
+
864
+ _CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-MM-DD (recheck clears it when the "
865
+ "date has passed, UTC)")
866
+
867
+
868
+ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
869
+ rm = parse_roadmap(text)
870
+ if rm.get(mid) is None:
871
+ raise RoadmapError(f"unknown milestone id {mid}")
872
+ if rm.blocker_table is None:
873
+ raise RoadmapError("the roadmap has no `## Blockers` table")
874
+ reason_cell = _clean_value(reason, "--reason")
875
+ if not reason_cell:
876
+ raise RoadmapError("--reason is empty")
877
+ if btype == "calendar":
878
+ when = iso_date(check_cmd)
879
+ if when is None:
880
+ raise RoadmapError(_CALENDAR_NEEDS_DATE)
881
+ check_cmd = when.isoformat()
882
+ if any(b.milestone == mid and b.type == btype and b.reason == reason.strip() for b in rm.blockers):
883
+ return text
884
+ check_cell = _clean_value(check_cmd, "--check")
885
+ if check_cell and "`" not in check_cell:
886
+ check_cell = f"`{check_cell}`"
887
+ eol = _eol(text)
888
+ row = f"| {mid} | {btype} | {reason_cell} | {check_cell or '-'} |"
889
+ lines = text.splitlines(keepends=True)
890
+ table = rm.blocker_table
891
+ after = table.rows[-1] if table.rows else table.header_idx + 1
892
+ if not lines[after].endswith(("\n", "\r")):
893
+ lines[after] += eol
894
+ lines.insert(after + 1, row + eol)
895
+ return "".join(lines)
896
+
897
+
898
+ def remove_blockers(text: str, rows: list[Blocker]) -> str:
899
+ drop = {b.line for b in rows}
900
+ return "".join(line for i, line in enumerate(text.splitlines(keepends=True)) if i not in drop)
901
+
902
+
903
+ def _find_blockers(rm: Roadmap, mid: str, btype: str, reason: str, check_cmd: str | None = None) -> list[Blocker]:
904
+ """The rows to remove. More than one row with this milestone, type and reason needs --check to pick one."""
905
+ rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
906
+ if not rows:
907
+ raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
908
+ if check_cmd is not None:
909
+ wanted = _cell_text(check_cmd)
910
+ rows = [b for b in rows if b.check == wanted]
911
+ if not rows:
912
+ raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}` and Check `{wanted}`")
913
+ if len({b.key for b in rows}) > 1:
914
+ raise RoadmapError(f"{len(rows)} `{btype}` blockers on {mid} have reason `{reason.strip()}`; "
915
+ "add --check with the row's Check text to pick one")
916
+ return rows[:1] # identical rows: one unblock removes one row (the first)
917
+
918
+
919
+ # -- headless / owner ------------------------------------------------------------------------------------------
920
+
921
+
922
+ def _stdin_isatty() -> bool:
923
+ try:
924
+ return sys.stdin is not None and sys.stdin.isatty()
925
+ except (ValueError, OSError):
926
+ return False
927
+
928
+
929
+ def _stdin_is_console() -> bool:
930
+ """Windows: GetConsoleMode on the stdin handle. isatty() is True for NUL (a character device); this is not."""
931
+ try:
932
+ import ctypes
933
+ import msvcrt
934
+ from ctypes import wintypes
935
+
936
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
937
+ kernel32.GetConsoleMode.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
938
+ kernel32.GetConsoleMode.restype = wintypes.BOOL
939
+ handle = msvcrt.get_osfhandle(sys.stdin.fileno())
940
+ mode = wintypes.DWORD()
941
+ return bool(kernel32.GetConsoleMode(handle, ctypes.byref(mode)))
942
+ except Exception: # no stdin, no fileno, not Windows: not a console
943
+ return False
944
+
945
+
946
+ def is_headless(env=None) -> bool:
947
+ """Unattended: any HEADLESS_VARS name in the environment (any value, even empty), stdin not a terminal, or on
948
+ Windows stdin not a console (GetConsoleMode fails for NUL, a pipe or a file)."""
949
+ env = os.environ if env is None else env
950
+ if any(name in env for name in HEADLESS_VARS):
951
+ return True
952
+ if not _stdin_isatty():
953
+ return True
954
+ return WINDOWS and not _stdin_is_console()
955
+
956
+
957
+ def _require_owner(by_owner: bool, what: str) -> None:
958
+ if is_headless():
959
+ raise RoadmapError(f"{what} is owner-only and this session is headless "
960
+ f"({', '.join(HEADLESS_VARS)} in the environment, or stdin is not a console)")
961
+ if not by_owner:
962
+ raise RoadmapError(f"{what} is owner-only: run it yourself with --by-owner")
963
+
964
+
965
+ # -- running Exit check / Check commands -----------------------------------------------------------------------
966
+
967
+
968
+ @contextlib.contextmanager
969
+ def merged_checkout(root: Path, branch: str):
970
+ """A temporary detached worktree at origin/<branch>: checks run the merged code, never the working tree."""
971
+ tmp = Path(tempfile.mkdtemp(prefix="caddis-roadmap-check-"))
972
+ worktree = tmp / "wt"
973
+ try:
974
+ _git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
975
+ yield worktree
976
+ finally:
977
+ _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
978
+ _git(root, "worktree", "prune", check=False)
979
+ shutil.rmtree(tmp, ignore_errors=True)
980
+
981
+
982
+ def _kill_tree(proc: subprocess.Popen) -> None:
983
+ if WINDOWS:
984
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
985
+ stdin=subprocess.DEVNULL)
986
+ else:
987
+ try:
988
+ os.killpg(proc.pid, signal.SIGKILL)
989
+ except (ProcessLookupError, PermissionError):
990
+ pass
991
+
992
+
993
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
994
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
995
+
996
+
997
+ def _job_limit_struct():
998
+ """ctypes JOBOBJECT_EXTENDED_LIMIT_INFORMATION."""
999
+ import ctypes
1000
+ from ctypes import wintypes
1001
+
1002
+ class _Basic(ctypes.Structure):
1003
+ _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64),
1004
+ ("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
1005
+ ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
1006
+ ("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
1007
+ ("SchedulingClass", wintypes.DWORD)]
1008
+
1009
+ class _Io(ctypes.Structure):
1010
+ _fields_ = [(name, ctypes.c_uint64) for name in (
1011
+ "ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
1012
+ "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
1013
+
1014
+ class _Extended(ctypes.Structure):
1015
+ _fields_ = [("BasicLimitInformation", _Basic), ("IoInfo", _Io), ("ProcessMemoryLimit", ctypes.c_size_t),
1016
+ ("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
1017
+ ("PeakJobMemoryUsed", ctypes.c_size_t)]
1018
+
1019
+ return _Extended
1020
+
1021
+
1022
+ class _WinJob:
1023
+ """A Windows job object that holds one process and every process it starts, so the tree can be killed even
1024
+ after the first process has exited (taskkill /T cannot find the children of a process that is gone).
1025
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: when the handle closes (also when this process dies), the tree dies."""
1026
+
1027
+ def __init__(self, proc: subprocess.Popen):
1028
+ import ctypes
1029
+ from ctypes import wintypes
1030
+
1031
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
1032
+ k.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
1033
+ k.CreateJobObjectW.restype = wintypes.HANDLE
1034
+ k.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
1035
+ k.SetInformationJobObject.restype = wintypes.BOOL
1036
+ k.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
1037
+ k.AssignProcessToJobObject.restype = wintypes.BOOL
1038
+ k.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
1039
+ k.TerminateJobObject.restype = wintypes.BOOL
1040
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
1041
+ k.CloseHandle.restype = wintypes.BOOL
1042
+ self._k = k
1043
+ self.handle = k.CreateJobObjectW(None, None)
1044
+ if not self.handle:
1045
+ raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
1046
+ info = _job_limit_struct()()
1047
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
1048
+ if not k.SetInformationJobObject(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
1049
+ ctypes.byref(info), ctypes.sizeof(info)):
1050
+ err = ctypes.get_last_error()
1051
+ k.CloseHandle(self.handle)
1052
+ raise OSError(err, "SetInformationJobObject failed")
1053
+ if not k.AssignProcessToJobObject(self.handle, int(proc._handle)): # type: ignore[attr-defined]
1054
+ err = ctypes.get_last_error()
1055
+ k.CloseHandle(self.handle)
1056
+ raise OSError(err, "AssignProcessToJobObject failed")
1057
+
1058
+ def limit_flags(self) -> int:
1059
+ """The job's LimitFlags (QueryInformationJobObject); for tests."""
1060
+ import ctypes
1061
+ from ctypes import wintypes
1062
+
1063
+ q = self._k.QueryInformationJobObject
1064
+ q.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p)
1065
+ q.restype = wintypes.BOOL
1066
+ info = _job_limit_struct()()
1067
+ if not q(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, ctypes.byref(info),
1068
+ ctypes.sizeof(info), None):
1069
+ raise OSError(ctypes.get_last_error(), "QueryInformationJobObject failed")
1070
+ return int(info.BasicLimitInformation.LimitFlags)
1071
+
1072
+ def terminate(self) -> None:
1073
+ self._k.TerminateJobObject(self.handle, 1)
1074
+
1075
+ def close(self) -> None:
1076
+ self._k.CloseHandle(self.handle)
1077
+
1078
+
1079
+ def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
1080
+ """Popen with no stdin, in its own process group (and, on Windows, its own job object)."""
1081
+ if WINDOWS:
1082
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
1083
+ else:
1084
+ kwargs["start_new_session"] = True
1085
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, **kwargs)
1086
+ job = None
1087
+ if WINDOWS:
1088
+ try:
1089
+ job = _WinJob(proc)
1090
+ except Exception: # no job object: taskkill /T is the fallback
1091
+ job = None
1092
+ except BaseException: # Ctrl+C while the job is made: do not leave the process running
1093
+ _kill(proc, None)
1094
+ raise
1095
+ return proc, job
1096
+
1097
+
1098
+ def _kill(proc: subprocess.Popen, job: "_WinJob | None") -> None:
1099
+ """Kill the whole tree: the job object when there is one, else taskkill /T (Windows) or the process group."""
1100
+ if job is not None:
1101
+ job.terminate()
1102
+ else:
1103
+ _kill_tree(proc)
1104
+
1105
+
1106
+ def _wait(proc: subprocess.Popen, job: "_WinJob | None", timeout: float, kill_always: bool) -> int | None:
1107
+ """Wait up to `timeout`; return the exit code, or None on timeout. The tree is killed on timeout, and also on
1108
+ a normal exit when `kill_always` (a check must not leave a background child running)."""
1109
+ try:
1110
+ rc: int | None = proc.wait(timeout=timeout)
1111
+ except subprocess.TimeoutExpired:
1112
+ rc = None
1113
+ if rc is None or kill_always:
1114
+ _kill(proc, job)
1115
+ if rc is None:
1116
+ with contextlib.suppress(subprocess.TimeoutExpired):
1117
+ proc.wait(timeout=10)
1118
+ return rc
1119
+
1120
+
1121
+ @contextlib.contextmanager
1122
+ def _owned(proc: subprocess.Popen, job: "_WinJob | None"):
1123
+ """On any exception, Ctrl+C (KeyboardInterrupt) included, kill the tree and re-raise; always close the job."""
1124
+ try:
1125
+ yield
1126
+ except BaseException:
1127
+ _kill(proc, job)
1128
+ raise
1129
+ finally:
1130
+ if job is not None:
1131
+ job.close()
1132
+
1133
+
1134
+ class BoundedTimeout(RoadmapError):
1135
+ """run_bounded timed out (exit 3)."""
1136
+
1137
+
1138
+ def run_bounded(argv: list[str], timeout: float, what: str, capture: bool = True) -> subprocess.CompletedProcess:
1139
+ """Run one network command (git fetch/ls-remote/push, gh, the merge chain) with no credential prompt.
1140
+
1141
+ Output goes to temp files, not pipes, so a child that keeps a pipe open cannot hang the read. A timeout kills
1142
+ the tree and raises BoundedTimeout (exit 3); Ctrl+C kills the tree and re-raises.
1143
+ """
1144
+ env = _quiet_env()
1145
+ if not capture:
1146
+ proc, job = _start(argv, env=env)
1147
+ with _owned(proc, job):
1148
+ rc = _wait(proc, job, timeout, kill_always=False)
1149
+ if rc is None:
1150
+ raise BoundedTimeout(f"{what} timed out after {timeout:.0f} s", caddis_exit.NOT_RUN)
1151
+ return subprocess.CompletedProcess(argv, rc, "", "")
1152
+ with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
1153
+ proc, job = _start(argv, env=env, stdout=out, stderr=err)
1154
+ with _owned(proc, job):
1155
+ rc = _wait(proc, job, timeout, kill_always=False)
1156
+ if rc is None:
1157
+ raise BoundedTimeout(f"{what} timed out after {timeout:.0f} s", caddis_exit.NOT_RUN)
1158
+ out.seek(0)
1159
+ err.seek(0)
1160
+ return subprocess.CompletedProcess(argv, rc, out.read().decode("utf-8", errors="replace"),
1161
+ err.read().decode("utf-8", errors="replace"))
1162
+
1163
+
1164
+ def _drain(pipe, out, guard: threading.Lock) -> None:
1165
+ """Copy a check's output to `out`; past OUTPUT_FILE_CAP keep only the last OUTPUT_TAIL_BYTES on disk."""
1166
+ fd = pipe.fileno()
1167
+ while True:
1168
+ try:
1169
+ chunk = os.read(fd, 65536)
1170
+ except OSError:
1171
+ return
1172
+ if not chunk:
1173
+ return
1174
+ with guard:
1175
+ out.write(chunk)
1176
+ if out.tell() > OUTPUT_FILE_CAP:
1177
+ out.seek(-OUTPUT_TAIL_BYTES, os.SEEK_END)
1178
+ tail = out.read()
1179
+ out.seek(0)
1180
+ out.truncate()
1181
+ out.write(tail)
1182
+
1183
+
1184
+ def run_check(cwd: Path, cmd: str, timeout: float) -> tuple[int | None, str]:
1185
+ """Run one command: (exit code, or None on timeout; the last 64 KB of output). It cannot hang the caller.
1186
+
1187
+ The process tree is killed on every exit, not only on timeout (Ctrl+C too, which is then re-raised), and the
1188
+ output file never passes 1 MB.
1189
+ """
1190
+ guard = threading.Lock()
1191
+ with tempfile.TemporaryFile() as out:
1192
+ proc, job = _start(cmd, shell=True, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1193
+ with _owned(proc, job):
1194
+ reader = threading.Thread(target=_drain, args=(proc.stdout, out, guard), daemon=True)
1195
+ reader.start()
1196
+ rc = _wait(proc, job, timeout, kill_always=True)
1197
+ reader.join(timeout=10)
1198
+ with guard:
1199
+ size = out.seek(0, os.SEEK_END)
1200
+ out.seek(max(0, size - OUTPUT_TAIL_BYTES))
1201
+ text = out.read().decode("utf-8", errors="replace")
1202
+ return rc, text
1203
+
1204
+
1205
+ def _failure(rc: int | None, timeout: float) -> str:
1206
+ return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
1207
+
1208
+
1209
+ # -- the write path ---------------------------------------------------------------------------------------------
1210
+
1211
+
1212
+ def open_pr(root: Path, gh_repo: str, branch: str, base: str, title: str, body: str) -> int:
1213
+ """Open a PR with the REST API and return its number. `gh` takes the account from GH_TOKEN when it is set."""
1214
+ proc = run_bounded(["gh", "api", f"repos/{gh_repo}/pulls", "-f", f"title={title}", "-f", f"head={branch}",
1215
+ "-f", f"base={base}", "-f", f"body={body}"], GH_TIMEOUT, "gh api (open the PR)")
1216
+ if proc.returncode != 0:
1217
+ raise RoadmapError(f"could not open the PR: {(proc.stderr or proc.stdout).strip()[:300]}",
1218
+ caddis_exit.BLOCKED)
1219
+ return int(json.loads(proc.stdout)["number"])
1220
+
1221
+
1222
+ def merge_pr(root: Path, pr: int, gh_repo: str) -> int:
1223
+ """Hand one PR to the kept merge tool; return its exit code. Its output goes to this console."""
1224
+ argv = [sys.executable, str(SCRIPTS_DIR / "caddis_merge_chain.py"), str(pr), "--repo", str(root),
1225
+ "--gh-repo", gh_repo, "--docs-only"]
1226
+ return run_bounded(argv, MERGE_CHAIN_TIMEOUT, "caddis_merge_chain.py", capture=False).returncode
1227
+
1228
+
1229
+ def _win_open_process(pid: int) -> tuple[bool | None, int]:
1230
+ """Windows: (still running, 0) when OpenProcess works; (None, GetLastError()) when it does not."""
1231
+ import ctypes
1232
+ from ctypes import wintypes
1233
+
1234
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
1235
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
1236
+ k.OpenProcess.restype = wintypes.HANDLE
1237
+ k.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
1238
+ k.GetExitCodeProcess.restype = wintypes.BOOL
1239
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
1240
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
1241
+ if not handle:
1242
+ return None, ctypes.get_last_error()
1243
+ code = wintypes.DWORD()
1244
+ ok = k.GetExitCodeProcess(handle, ctypes.byref(code))
1245
+ k.CloseHandle(handle)
1246
+ return bool(ok) and code.value == 259, 0 # STILL_ACTIVE
1247
+
1248
+
1249
+ def _pid_alive(pid: int) -> bool:
1250
+ if pid <= 0:
1251
+ return False
1252
+ if WINDOWS:
1253
+ running, error = _win_open_process(pid)
1254
+ if running is None:
1255
+ return error == 5 # ERROR_ACCESS_DENIED: the process exists, this user may not open it
1256
+ return running
1257
+ try:
1258
+ os.kill(pid, 0)
1259
+ except ProcessLookupError:
1260
+ return False
1261
+ except PermissionError:
1262
+ return True
1263
+ return True
1264
+
1265
+
1266
+ class _Lock:
1267
+ """Exclusive file lock. On timeout, a stale lock is taken over; a live one refuses.
1268
+
1269
+ Stale: the holder's pid is dead on this host, or the file is empty or not JSON and older than 60 s. Takeover is
1270
+ race-safe: rename the stale file to a unique name (only one contender's rename succeeds), check the renamed file
1271
+ still holds what was judged stale, then create the lock with O_EXCL; only the winner proceeds.
1272
+ """
1273
+
1274
+ def __init__(self, path: Path, timeout: float | None = None):
1275
+ self.path, self.fd = path, None
1276
+ self.timeout = LOCK_TIMEOUT if timeout is None else timeout
1277
+ self.denied: PermissionError | None = None # the last Windows "busy" refusal, reported on timeout
1278
+
1279
+ def _try(self) -> bool:
1280
+ try:
1281
+ self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
1282
+ except FileExistsError:
1283
+ return False
1284
+ except PermissionError as exc:
1285
+ # Windows refuses a create while the last holder's delete is still pending: busy, not an error.
1286
+ if not _WINDOWS:
1287
+ raise
1288
+ self.denied = exc
1289
+ return False
1290
+ os.write(self.fd, json.dumps({"pid": os.getpid(), "host": socket.gethostname(),
1291
+ "started_utc": _utc()}).encode())
1292
+ return True
1293
+
1294
+ def _stale(self) -> bytes | None:
1295
+ """The lock file's bytes when it is stale, else None."""
1296
+ try:
1297
+ raw = self.path.read_bytes()
1298
+ age = time.time() - self.path.stat().st_mtime
1299
+ except OSError:
1300
+ return None
1301
+ try:
1302
+ holder = json.loads(raw.decode("utf-8"))
1303
+ except ValueError: # empty or not JSON (a writer died mid-write)
1304
+ holder = None
1305
+ if not isinstance(holder, dict):
1306
+ return raw if age > LOCK_MALFORMED_STALE_S else None
1307
+ try:
1308
+ pid = int(holder.get("pid", 0))
1309
+ except (TypeError, ValueError):
1310
+ return raw if age > LOCK_MALFORMED_STALE_S else None
1311
+ if holder.get("host") == socket.gethostname() and not _pid_alive(pid):
1312
+ return raw
1313
+ return None
1314
+
1315
+ def _take_over(self) -> bool:
1316
+ judged = self._stale()
1317
+ if judged is None:
1318
+ return False
1319
+ grave = self.path.with_name(f"{self.path.name}.stale-{os.getpid()}-{uuid.uuid4().hex}")
1320
+ try:
1321
+ os.rename(self.path, grave)
1322
+ except FileNotFoundError: # another contender took it first: the lock is held (exit 3)
1323
+ return False
1324
+ except OSError: # the holder still has it open
1325
+ return False
1326
+ try:
1327
+ try:
1328
+ content = grave.read_bytes()
1329
+ except FileNotFoundError: # gone under us: another contender has it; held, not an error
1330
+ return False
1331
+ if content != judged:
1332
+ # A fresh lock replaced the stale one between the check and the rename: put it back.
1333
+ with contextlib.suppress(OSError):
1334
+ os.link(grave, self.path)
1335
+ return False
1336
+ finally:
1337
+ grave.unlink(missing_ok=True)
1338
+ return self._try()
1339
+
1340
+ def __enter__(self) -> "_Lock":
1341
+ self.path.parent.mkdir(parents=True, exist_ok=True)
1342
+ deadline = time.monotonic() + self.timeout
1343
+ while not self._try():
1344
+ if time.monotonic() >= deadline:
1345
+ if self._take_over():
1346
+ print(f"took over a stale lock {self.path}", file=sys.stderr)
1347
+ return self
1348
+ why = f" (last refusal: {self.denied})" if self.denied else ""
1349
+ raise RoadmapError(f"{self.path} is held by another write after {self.timeout:.0f} s{why}",
1350
+ caddis_exit.NOT_RUN)
1351
+ time.sleep(0.2)
1352
+ return self
1353
+
1354
+ def __exit__(self, *_exc) -> None:
1355
+ if self.fd is not None:
1356
+ os.close(self.fd)
1357
+ self.path.unlink(missing_ok=True)
1358
+
1359
+
1360
+ def _utc() -> str:
1361
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1362
+
1363
+
1364
+ # -- runner locks (written by caddis_roadmap_run.py; read here so `next` skips a batch another runner holds) ------
1365
+
1366
+ RUNNER_DIR = "caddis-runner" # in the git common dir: every worktree of the repo sees the same locks
1367
+ RUNNER_LOCK_GRACE_H = 1.0 # a runner may finish its last roadmap write a little after its hour cap
1368
+ MALFORMED_LOCK_STALE_H = 24.0 # a lock that is not the JSON object is stale only after this long
1369
+ CLAIM_TIMEOUT = 300 # `next` plus the batch claim, under one lock; a fetch inside it can take 120 s
1370
+ BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<phase>`, or `<plan>#*` (plan mode)
1371
+
1372
+
1373
+ def runner_state_dir(root: Path) -> Path | None:
1374
+ """`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
1375
+ proc = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"], capture_output=True, text=True,
1376
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
1377
+ if proc.returncode != 0 or not proc.stdout.strip():
1378
+ return None
1379
+ common = Path(proc.stdout.strip())
1380
+ if not common.is_absolute():
1381
+ common = Path(root) / common
1382
+ return common.resolve() / RUNNER_DIR
1383
+
1384
+
1385
+ def runner_lock_path(root: Path, kind: str, slug: str) -> Path | None:
1386
+ """The runner lock of a roadmap (`kind` roadmap) or a plan (`kind` plan); None outside a git repo."""
1387
+ base = runner_state_dir(root)
1388
+ return None if base is None else base / f"{kind}-{slug}" / "runner.lock"
1389
+
1390
+
1391
+ def last_stop_path(root: Path, kind: str, slug: str) -> Path | None:
1392
+ """The last stop the lock-holding runner recorded (reason, exit, utc), next to its lock."""
1393
+ base = runner_state_dir(root)
1394
+ return None if base is None else base / f"{kind}-{slug}" / "last-stop.json"
1395
+
1396
+
1397
+ def claim_lock_path(root: Path) -> Path | None:
1398
+ base = runner_state_dir(root)
1399
+ return None if base is None else base / "claim.lock"
1400
+
1401
+
1402
+ def read_runner_lock(path: Path) -> dict | None:
1403
+ """The lock's JSON object; None when the file is missing; {} when it is empty, not JSON or not an object."""
1404
+ try:
1405
+ raw = Path(path).read_text(encoding="utf-8")
1406
+ except FileNotFoundError:
1407
+ return None
1408
+ except (OSError, UnicodeDecodeError):
1409
+ return {}
1410
+ try:
1411
+ holder = json.loads(raw)
1412
+ except ValueError:
1413
+ return {}
1414
+ return holder if isinstance(holder, dict) else {}
1415
+
1416
+
1417
+ def _parse_utc(value) -> datetime.datetime | None:
1418
+ if not isinstance(value, str):
1419
+ return None
1420
+ try:
1421
+ moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
1422
+ except ValueError:
1423
+ return None
1424
+ return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc)
1425
+
1426
+
1427
+ def _hours(value) -> float | None:
1428
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1429
+ return None
1430
+ return float(value) if 0 < value < float("inf") else None
1431
+
1432
+
1433
+ PID_START_TOLERANCE_S = 5.0 # `started_utc` has whole seconds; the holder's own process started before it
1434
+
1435
+
1436
+ def _filetime_utc(value: int) -> datetime.datetime:
1437
+ return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(microseconds=value // 10)
1438
+
1439
+
1440
+ def _win_process_start(pid: int) -> datetime.datetime | None:
1441
+ import ctypes
1442
+ from ctypes import wintypes
1443
+
1444
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
1445
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
1446
+ k.OpenProcess.restype = wintypes.HANDLE
1447
+ k.GetProcessTimes.argtypes = (wintypes.HANDLE,) + (ctypes.POINTER(wintypes.FILETIME),) * 4
1448
+ k.GetProcessTimes.restype = wintypes.BOOL
1449
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
1450
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
1451
+ if not handle:
1452
+ return None
1453
+ try:
1454
+ times = [wintypes.FILETIME() for _ in range(4)]
1455
+ if not k.GetProcessTimes(handle, *(ctypes.byref(x) for x in times)):
1456
+ return None
1457
+ created = times[0]
1458
+ return _filetime_utc((created.dwHighDateTime << 32) | created.dwLowDateTime)
1459
+ finally:
1460
+ k.CloseHandle(handle)
1461
+
1462
+
1463
+ def _proc_process_start(pid: int) -> datetime.datetime | None:
1464
+ """POSIX with /proc: field 22 of /proc/<pid>/stat (clock ticks after boot) plus `btime` of /proc/stat."""
1465
+ try:
1466
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
1467
+ ticks = int(stat.rsplit(")", 1)[1].split()[19])
1468
+ btime = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines()
1469
+ if line.startswith("btime "))
1470
+ hz = os.sysconf("SC_CLK_TCK")
1471
+ except (OSError, ValueError, IndexError, StopIteration, AttributeError):
1472
+ return None
1473
+ return datetime.datetime.fromtimestamp(btime + ticks / hz, datetime.timezone.utc)
1474
+
1475
+
1476
+ def process_start_time(pid: int) -> datetime.datetime | None:
1477
+ """When the process now holding `pid` started, or None when it cannot be read (psutil when installed, else
1478
+ GetProcessTimes on Windows, else /proc)."""
1479
+ try:
1480
+ import psutil # optional
1481
+ return datetime.datetime.fromtimestamp(psutil.Process(pid).create_time(), datetime.timezone.utc)
1482
+ except ImportError:
1483
+ pass
1484
+ except Exception: # no such process, access denied: fall through to the platform reader
1485
+ pass
1486
+ try:
1487
+ return _win_process_start(pid) if WINDOWS else _proc_process_start(pid)
1488
+ except Exception:
1489
+ return None
1490
+
1491
+
1492
+ def runner_lock_stale(path: Path, now: datetime.datetime | None = None) -> bool | None:
1493
+ """None when there is no lock. On this host: stale when its pid is not running, or the process now holding that
1494
+ pid started after `started_utc` (pid reuse after a reboot; when the start time cannot be read: live). Another
1495
+ host's lock: stale when `started_utc` is older than the `max_hours` the lock itself records (plus a 1 h grace); with
1496
+ no readable `max_hours` or `started_utc` it never ages out (the owner deletes it). A lock that is not the JSON
1497
+ object is stale only when the file is older than 24 h."""
1498
+ holder = read_runner_lock(path)
1499
+ if holder is None:
1500
+ return None
1501
+ now = now or datetime.datetime.now(datetime.timezone.utc)
1502
+ try:
1503
+ pid = int(holder.get("pid"))
1504
+ except (TypeError, ValueError):
1505
+ pid = None
1506
+ host = holder.get("host")
1507
+ if pid is None or not isinstance(host, str):
1508
+ try:
1509
+ mtime = datetime.datetime.fromtimestamp(Path(path).stat().st_mtime, datetime.timezone.utc)
1510
+ except OSError:
1511
+ return None
1512
+ return now - mtime > datetime.timedelta(hours=MALFORMED_LOCK_STALE_H)
1513
+ started, max_hours = _parse_utc(holder.get("started_utc")), _hours(holder.get("max_hours"))
1514
+ if host.casefold() == socket.gethostname().casefold():
1515
+ if not _pid_alive(pid):
1516
+ return True
1517
+ born = process_start_time(pid) if started is not None else None
1518
+ return born is not None and born > started + datetime.timedelta(seconds=PID_START_TOLERANCE_S)
1519
+ if started is None or max_hours is None:
1520
+ return False
1521
+ return now - started > datetime.timedelta(hours=max_hours + RUNNER_LOCK_GRACE_H)
1522
+
1523
+
1524
+ def _old_plan_locks(root: Path) -> dict[str, str]:
1525
+ """Upgrade window, read only: a plan-mode runner of the base version holds `.caddis/plans/.local/<slug>/
1526
+ runner.lock` (a plain pid, or the JSON lock). A plain pid holds the whole plan only while that pid runs AND the
1527
+ lock file is newer than that process's start (else the pid was reused, or the start cannot be read: ignored)."""
1528
+ out: dict[str, str] = {}
1529
+ try:
1530
+ locks = sorted((Path(root) / ".caddis" / "plans" / ".local").glob("*/runner.lock"))
1531
+ except OSError:
1532
+ return out
1533
+ for lock in locks:
1534
+ try:
1535
+ raw = lock.read_text(encoding="utf-8").strip()
1536
+ except (OSError, UnicodeDecodeError):
1537
+ continue
1538
+ if raw.isdigit():
1539
+ born = process_start_time(int(raw)) if _pid_alive(int(raw)) else None
1540
+ try:
1541
+ written = datetime.datetime.fromtimestamp(lock.stat().st_mtime, datetime.timezone.utc)
1542
+ except OSError:
1543
+ written = None
1544
+ live = born is not None and written is not None and written > born
1545
+ else:
1546
+ holder = read_runner_lock(lock) or {}
1547
+ live = isinstance(holder.get("pid"), int) and runner_lock_stale(lock) is False
1548
+ if live:
1549
+ out.setdefault(f"{lock.parent.name}#*", f"plan {lock.parent.name}, old lock {lock}")
1550
+ return out
1551
+
1552
+
1553
+ def live_runner_holders(root: Path, exclude: Path | None = None) -> dict[str, dict]:
1554
+ """Batch (`<plan>#<n>` or `<plan>#*`) -> {"kind": "lock" | "claim", "holder": "roadmap <slug>" | "plan <slug>"},
1555
+ for every live runner lock of this repo but `exclude`, every base-version plan lock that still runs, and every
1556
+ persistent claim of a phase whose PR waits for a merge."""
1557
+ out: dict[str, dict] = {}
1558
+ base = runner_state_dir(root)
1559
+ if base is None:
1560
+ return out
1561
+ try:
1562
+ locks = sorted(base.glob("*/runner.lock")) # one folder per runner: roadmap-<slug>/, plan-<slug>/
1563
+ except OSError:
1564
+ return out
1565
+ for lock in locks:
1566
+ if exclude is not None and lock.parent.name == Path(exclude).parent.name:
1567
+ continue
1568
+ if runner_lock_stale(lock) is not False:
1569
+ continue
1570
+ batch = (read_runner_lock(lock) or {}).get("batch")
1571
+ if isinstance(batch, str) and BATCH_RE.match(batch):
1572
+ out.setdefault(batch, {"kind": "lock", "holder": lock.parent.name.replace("-", " ", 1)})
1573
+ for batch, holder in _old_plan_locks(root).items():
1574
+ out.setdefault(batch, {"kind": "lock", "holder": holder})
1575
+ # A phase whose PR waits for a merge stays held by its persistent claim (`claims/<plan>#<n>.json`).
1576
+ try:
1577
+ claims = sorted((base / "claims").glob("*.json"))
1578
+ except OSError:
1579
+ claims = []
1580
+ for claim in claims:
1581
+ record = read_runner_lock(claim) or {}
1582
+ batch = record.get("batch") or claim.stem
1583
+ if isinstance(batch, str) and BATCH_RE.match(batch):
1584
+ out.setdefault(batch, {"kind": "claim", "holder": f"roadmap {record.get('roadmap', '?')}"})
1585
+ return out
1586
+
1587
+
1588
+ def holder_label(holder: dict | str) -> str:
1589
+ """How a reason names a holder: `<holder> (its runner lock)` or `<holder> (its claim: its PR waits for a merge)`."""
1590
+ if isinstance(holder, str):
1591
+ return holder
1592
+ what = "its claim: its PR waits for a merge" if holder.get("kind") == "claim" else "its runner lock"
1593
+ return f"{holder.get('holder')} ({what})"
1594
+
1595
+
1596
+ def live_runner_batches(root: Path, exclude: Path | None = None) -> dict[str, str]:
1597
+ """live_runner_holders as batch -> label (see holder_label)."""
1598
+ return {batch: holder_label(h) for batch, h in live_runner_holders(root, exclude).items()}
1599
+
1600
+
1601
+ def other_roadmap_holders(root: Path, slug: str) -> dict[str, dict]:
1602
+ """live_runner_holders without this roadmap's own runner lock (its batch is the one it is running)."""
1603
+ return live_runner_holders(root, exclude=runner_lock_path(root, "roadmap", slug))
1604
+
1605
+
1606
+ def other_roadmap_batches(root: Path, slug: str) -> dict[str, str]:
1607
+ """live_runner_batches without this roadmap's own runner (its batch is the one it is running)."""
1608
+ own = runner_lock_path(root, "roadmap", slug)
1609
+ return live_runner_batches(root, exclude=own)
1610
+
1611
+
1612
+ def _diff(old: str, new: str, path: str) -> str:
1613
+ return "".join(difflib.unified_diff(old.splitlines(keepends=True), new.splitlines(keepends=True),
1614
+ f"a/{path}", f"b/{path}"))
1615
+
1616
+
1617
+ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bool = False, out=print) -> int:
1618
+ """Apply `edit(snapshot) -> text` to a fresh origin copy and land it as one docs PR. Return a caddis exit code.
1619
+
1620
+ `edit` runs under the lock on a fresh, valid origin copy; it raises RoadmapError to refuse (nothing written).
1621
+ """
1622
+ root = Path(root)
1623
+ path = roadmap_path(slug)
1624
+ if dry_run:
1625
+ snap = load(root, slug)
1626
+ new = edit(snap)
1627
+ diff = _diff(snap.text, new, path)
1628
+ out(f"would write:\n{diff}" if diff else "no change")
1629
+ return caddis_exit.CLEAN
1630
+ with _Lock(lock_path(root, slug)):
1631
+ snap = load(root, slug)
1632
+ new = edit(snap)
1633
+ if new == snap.text:
1634
+ out("no change")
1635
+ return caddis_exit.CLEAN
1636
+ gh_repo = gh_repo_of(root)
1637
+ if gh_repo is None:
1638
+ raise RoadmapError("origin is not a GitHub remote; cannot open a docs PR", caddis_exit.NOT_RUN)
1639
+ stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
1640
+ branch = f"roadmap/{slug}-{mid.lower()}-{stamp}"
1641
+ title = f"docs(roadmap): {slug} {mid} {change}"
1642
+ tmp = Path(tempfile.mkdtemp(prefix="caddis-roadmap-"))
1643
+ worktree = tmp / "wt"
1644
+ pr = None
1645
+ rc = caddis_exit.BLOCKED
1646
+ merge_exit: int | str | None = None
1647
+ try:
1648
+ _git(root, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{snap.branch}")
1649
+ (worktree / path).write_bytes(new.encode("utf-8"))
1650
+ _git(worktree, "add", "--", path)
1651
+ _git(worktree, "commit", "--quiet", "-m", title)
1652
+ _git(worktree, "push", "--quiet", "-u", "origin", branch)
1653
+ try:
1654
+ pr = open_pr(root, gh_repo, branch, snap.branch, title,
1655
+ f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
1656
+ except Exception:
1657
+ _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1658
+ raise
1659
+ try:
1660
+ rc = merge_pr(root, pr, gh_repo)
1661
+ except BoundedTimeout:
1662
+ merge_exit = "timeout"
1663
+ raise
1664
+ out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
1665
+ if rc == caddis_exit.CLEAN:
1666
+ _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1667
+ else:
1668
+ rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1669
+ finally:
1670
+ _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
1671
+ _git(root, "worktree", "prune", check=False)
1672
+ _git(root, "branch", "-D", branch, check=False)
1673
+ shutil.rmtree(tmp, ignore_errors=True)
1674
+ _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
1675
+ "pr": pr, "merge_exit": merge_exit if merge_exit is not None
1676
+ else (rc if pr is not None else None)})
1677
+ return rc
1678
+
1679
+
1680
+ def _audit(root: Path, slug: str, entry: dict) -> None:
1681
+ log = local_dir(root, slug) / "changes.jsonl"
1682
+ log.parent.mkdir(parents=True, exist_ok=True)
1683
+ with log.open("a", encoding="utf-8") as fh:
1684
+ fh.write(json.dumps(entry) + "\n")
1685
+
1686
+
1687
+ # -- write commands ----------------------------------------------------------------------------------------------
1688
+
1689
+
1690
+ def _known(snap: Snapshot, mid: str) -> Milestone:
1691
+ ms = snap.roadmap.get(mid)
1692
+ if ms is None:
1693
+ raise RoadmapError(f"unknown milestone id {mid}")
1694
+ return ms
1695
+
1696
+
1697
+ def _drop_reason(reason: str | None) -> str:
1698
+ """The checked text of a `not-needed` reason: `dropped: <why>` or a `.caddis/parking-lot/<file>.md` path."""
1699
+ if reason is None or not reason.strip():
1700
+ raise RoadmapError("setting Status not-needed needs --reason \"dropped: <why>\" or "
1701
+ "--reason .caddis/parking-lot/<file>.md")
1702
+ if "|" in reason or len(reason.splitlines()) > 1 or "\n" in reason or "\r" in reason:
1703
+ raise RoadmapError("--reason must be one line with no `|`")
1704
+ text = reason.strip()
1705
+ if text.startswith(DROPPED_PREFIX):
1706
+ if not text[len(DROPPED_PREFIX):].strip():
1707
+ raise RoadmapError("--reason `dropped:` needs the reason after it")
1708
+ return text
1709
+ if PARKING_RE.match(text):
1710
+ return text
1711
+ raise RoadmapError(f"--reason must start with `{DROPPED_PREFIX} ` or be a .caddis/parking-lot/<file>.md path, "
1712
+ f"not `{text}`")
1713
+
1714
+
1715
+ def add_note(text: str, item: str) -> str:
1716
+ """Append `- <item>` to the `## Notes` section (added at the end when missing). Line endings kept."""
1717
+ eol = _eol(text)
1718
+ lines = text.splitlines(keepends=True)
1719
+ bare = [line.rstrip("\r\n") for line in lines]
1720
+ front_end = _frontmatter_end(bare)
1721
+ start = next((i for i, line in enumerate(bare) if i > front_end and line.rstrip() == "## Notes"), None)
1722
+ if start is None:
1723
+ body = text if not text or text.endswith(("\n", "\r")) else text + eol
1724
+ sep = "" if not body or body.endswith(eol + eol) else eol
1725
+ return f"{body}{sep}## Notes{eol}{eol}- {item}{eol}"
1726
+ end = len(bare)
1727
+ for i in range(start + 1, len(bare)):
1728
+ match = HEADING_RE.match(bare[i])
1729
+ if match and len(match.group(1)) <= 2:
1730
+ end = i
1731
+ break
1732
+ last = max((i for i in range(start + 1, end) if bare[i].strip()), default=start)
1733
+ if not lines[last].endswith(("\n", "\r")):
1734
+ lines[last] += eol
1735
+ new = [f"- {item}{eol}"]
1736
+ if last == start or not bare[last].lstrip().startswith("- "):
1737
+ new.insert(0, eol) # a blank line between the heading or a paragraph and the list
1738
+ lines[last + 1:last + 1] = new
1739
+ return "".join(lines)
1740
+
1741
+
1742
+ def set_cell(root: Path, slug: str, mid: str, col: str, value: str, by_owner: bool = False,
1743
+ dry_run: bool = False, out=print, reason: str | None = None,
1744
+ today: datetime.date | None = None) -> int:
1745
+ drop = col == "Status" and value == "not-needed"
1746
+ if reason is not None and not drop:
1747
+ raise RoadmapError("--reason is only for --col Status --value not-needed")
1748
+ if col not in SETTABLE:
1749
+ raise RoadmapError(f"--col must be one of {', '.join(SETTABLE)}, not `{col}`")
1750
+ if col == "Status":
1751
+ if value == "done":
1752
+ raise RoadmapError("only `rollup` may write Status done")
1753
+ if value not in STATUSES:
1754
+ raise RoadmapError(f"Status `{value}` is not one of {', '.join(STATUSES)}")
1755
+ if value == "not-needed":
1756
+ _require_owner(by_owner, "setting Status not-needed")
1757
+ reason = _drop_reason(reason)
1758
+ if col == "Owner":
1759
+ _require_owner(by_owner, "changing Owner")
1760
+
1761
+ def edit(snap: Snapshot) -> str:
1762
+ ms = _known(snap, mid)
1763
+ new = edit_cell(snap.text, mid, col, value)
1764
+ if not drop or ms.status == "not-needed": # already dropped: no second note
1765
+ return new
1766
+ assert reason is not None
1767
+ if PARKING_RE.match(reason) and show(snap.root, snap.branch, reason) is None:
1768
+ raise RoadmapError(f"--reason {reason} is not on origin/{snap.branch}")
1769
+ new = add_note(new, f"{mid} not-needed: {reason} ({(today or _utc_today()).isoformat()})")
1770
+ errors = parse_roadmap(new).errors
1771
+ if errors: # never land a roadmap that fails closed
1772
+ raise RoadmapError("the note would make the roadmap malformed: " + "; ".join(errors))
1773
+ return new
1774
+
1775
+ return write_change(root, slug, mid, f"{col} {value or '-'}", edit, dry_run, out)
1776
+
1777
+
1778
+ def block(root: Path, slug: str, mid: str, btype: str, reason: str, check_cmd: str = "", by_owner: bool = False,
1779
+ dry_run: bool = False, out=print) -> int:
1780
+ if btype not in BLOCKER_TYPES:
1781
+ raise RoadmapError(f"--type must be one of {', '.join(BLOCKER_TYPES)}, not `{btype}`")
1782
+ if btype == "calendar":
1783
+ if iso_date(check_cmd) is None:
1784
+ raise RoadmapError(_CALENDAR_NEEDS_DATE)
1785
+ # A calendar Check is a date that recheck compares, never a command it runs: no owner check.
1786
+ elif check_cmd.strip():
1787
+ # recheck runs this command unattended later, so only the owner may add one.
1788
+ _require_owner(by_owner, "adding a Check command")
1789
+ return write_change(root, slug, mid, f"block {btype}",
1790
+ lambda snap: add_blocker(snap.text, mid, btype, reason, check_cmd), dry_run, out)
1791
+
1792
+
1793
+ def unblock(root: Path, slug: str, mid: str, btype: str, reason: str, by_owner: bool = False,
1794
+ dry_run: bool = False, out=print, check_cmd: str | None = None) -> int:
1795
+ if btype not in BLOCKER_TYPES:
1796
+ raise RoadmapError(f"--type must be one of {', '.join(BLOCKER_TYPES)}, not `{btype}`")
1797
+ if btype == "owner-gate":
1798
+ _require_owner(by_owner, "clearing an owner gate")
1799
+
1800
+ def edit(snap: Snapshot) -> str:
1801
+ _known(snap, mid)
1802
+ return remove_blockers(snap.text, _find_blockers(snap.roadmap, mid, btype, reason, check_cmd))
1803
+
1804
+ return write_change(root, slug, mid, f"unblock {btype}", edit, dry_run, out)
1805
+
1806
+
1807
+ def _rollup_gate(snap: Snapshot, mid: str) -> Milestone | None:
1808
+ """The milestone when rollup may proceed; None when it is already finished. Raise to refuse."""
1809
+ ms = _known(snap, mid)
1810
+ if ms.status in FINISHED:
1811
+ return None
1812
+ if not ms.phases and not ms.exit_check:
1813
+ raise RoadmapError(f"{mid} has no phase refs and no Exit check: nothing can show it is done")
1814
+ rm = snap.roadmap
1815
+ unmet = [d for d in ms.depends if rm.get(d) is None or rm.get(d).status not in FINISHED]
1816
+ if unmet:
1817
+ raise RoadmapError(f"{mid} depends on milestone(s) not done: " + ", ".join(
1818
+ f"{d} ({rm.get(d).status if rm.get(d) else 'unknown'})" for d in unmet), caddis_exit.BLOCKED)
1819
+ not_done = [ref for ref in ms.phases if snap.phases.state(ref) != "done"]
1820
+ if not_done:
1821
+ raise RoadmapError(f"{mid}: not merged on origin/{snap.branch} yet: {', '.join(not_done)}",
1822
+ caddis_exit.BLOCKED)
1823
+ open_blocks = [b for b in snap.roadmap.blockers_of(mid) if b.type != "data-condition"]
1824
+ if open_blocks:
1825
+ raise RoadmapError(f"{mid} has open blocker(s): " + "; ".join(f"{b.type} {b.reason}" for b in open_blocks),
1826
+ caddis_exit.BLOCKED)
1827
+ return ms
1828
+
1829
+
1830
+ def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) -> int:
1831
+ root = Path(root)
1832
+ snap = load(root, slug)
1833
+ ms = _rollup_gate(snap, mid)
1834
+ if ms is None:
1835
+ out(f"{mid} is already {_known(snap, mid).status}")
1836
+ return caddis_exit.CLEAN
1837
+
1838
+ def to_status(value: str):
1839
+ def edit(fresh: Snapshot) -> str:
1840
+ # Re-check on the fresh origin copy under the lock: status, merged phases and blockers may have moved.
1841
+ if _rollup_gate(fresh, mid) is None:
1842
+ return fresh.text
1843
+ return edit_cell(fresh.text, mid, "Status", value)
1844
+ return edit
1845
+
1846
+ if dry_run:
1847
+ if ms.exit_check:
1848
+ out(f"would run: {ms.exit_check}")
1849
+ return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
1850
+ rc: int | None = 0
1851
+ if ms.exit_check:
1852
+ with merged_checkout(root, snap.branch) as cwd:
1853
+ rc, _output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
1854
+ if rc == 0:
1855
+ out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
1856
+ return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
1857
+ failure = _failure(rc, EXIT_CHECK_TIMEOUT)
1858
+ if any(b.type == "data-condition" for b in snap.roadmap.blockers_of(mid)):
1859
+ out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending")
1860
+ write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
1861
+ to_status("code-done/data-pending"), False, out)
1862
+ return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
1863
+ # The command's output is never written to the roadmap: it can hold paths or secrets.
1864
+ reason = f"exit check failing ({failure})"
1865
+ out(f"{mid}: {reason}")
1866
+ write_rc = write_change(root, slug, mid, "block external",
1867
+ lambda fresh: add_blocker(fresh.text, mid, "external", reason), False, out)
1868
+ return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
1869
+
1870
+
1871
+ def _due_calendar(rm: Roadmap, today: datetime.date) -> list[Blocker]:
1872
+ """Calendar rows whose Check is an ISO date on or before today. A Check of `-` (or any non-date) is never due."""
1873
+ due = []
1874
+ for b in rm.blockers:
1875
+ if b.type != "calendar":
1876
+ continue
1877
+ when = iso_date(b.check)
1878
+ if when is not None and when <= today:
1879
+ due.append(b)
1880
+ return due
1881
+
1882
+
1883
+ def recheck(root: Path, slug: str, dry_run: bool = False, out=print, today: datetime.date | None = None) -> int:
1884
+ root = Path(root)
1885
+ today = today or _utc_today()
1886
+ snap = load(root, slug)
1887
+ rows = [b for b in snap.roadmap.blockers if b.type == "data-condition" and b.check]
1888
+ due = _due_calendar(snap.roadmap, today)
1889
+ if dry_run:
1890
+ for b in rows:
1891
+ out(f"would run: {b.check}")
1892
+ for b in due:
1893
+ out(f"would clear: {b.milestone} calendar {b.reason} (date {b.check} has passed)")
1894
+ keys = {b.key for b in rows} | {b.key for b in due}
1895
+ out("if every check passes:")
1896
+ write_change(root, slug, "all", "unblock data-condition",
1897
+ lambda fresh: remove_blockers(fresh.text, [b for b in fresh.roadmap.blockers if b.key in keys]),
1898
+ True, out)
1899
+ return caddis_exit.CLEAN
1900
+ passed: dict[str, set] = {}
1901
+ for b in due: # no command runs: the date in Check has passed
1902
+ passed.setdefault(b.milestone, set()).add(b.key)
1903
+ if rows:
1904
+ with merged_checkout(root, snap.branch) as cwd:
1905
+ for b in rows:
1906
+ try:
1907
+ rc, _output = run_check(cwd, b.check, RECHECK_TIMEOUT)
1908
+ except Exception as exc: # one bad row must not stop the others
1909
+ detail = str(exc).splitlines()[0] if str(exc) else ""
1910
+ out(f"{b.milestone}: check not run: {type(exc).__name__}: {detail}")
1911
+ continue
1912
+ if rc == 0:
1913
+ passed.setdefault(b.milestone, set()).add(b.key)
1914
+ n = 0
1915
+ for mid, keys in passed.items():
1916
+ removed: list[int] = []
1917
+
1918
+ def edit(fresh: Snapshot, keys=keys, removed=removed) -> str:
1919
+ # Only rows whose milestone, type, reason AND check text match a check that passed.
1920
+ drop = [b for b in fresh.roadmap.blockers if b.key in keys]
1921
+ removed[:] = [len(drop)]
1922
+ return remove_blockers(fresh.text, drop)
1923
+
1924
+ types = sorted({key[1] for key in keys})
1925
+ try:
1926
+ if write_change(root, slug, mid, "unblock " + ", ".join(types), edit, False, out) == caddis_exit.CLEAN:
1927
+ n += removed[0] if removed else 0
1928
+ except Exception as exc: # report and go on to the next milestone
1929
+ text = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
1930
+ out(f"{mid}: not cleared: {text}")
1931
+ out(f"cleared {n}")
1932
+ return caddis_exit.CLEAN if n > 0 else caddis_exit.BLOCKED
1933
+
1934
+
1935
+ # -- CLI ---------------------------------------------------------------------------------------------------------
1936
+
1937
+
1938
+ class _Parser(argparse.ArgumentParser):
1939
+ def error(self, message: str): # type: ignore[override]
1940
+ self.print_usage(sys.stderr)
1941
+ print(f"ERROR: {message}", file=sys.stderr)
1942
+ sys.exit(caddis_exit.ERROR)
1943
+
1944
+
1945
+ def _repo_root(raw: str) -> Path:
1946
+ proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
1947
+ stdin=subprocess.DEVNULL)
1948
+ if proc.returncode != 0:
1949
+ raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
1950
+ return Path(proc.stdout.strip())
1951
+
1952
+
1953
+ def list_roadmaps(root: Path) -> list[str]:
1954
+ """Slugs of the roadmaps on origin's default branch: a folder under .caddis/roadmaps/ that holds roadmap.md."""
1955
+ return list_roadmaps_checked(root)[0]
1956
+
1957
+
1958
+ def list_roadmaps_checked(root: Path) -> tuple[list[str], set[str] | None]:
1959
+ """(slugs, every folder name under .caddis/roadmaps/ on origin). The folder set is None when git could not list
1960
+ the tree: a caller that deletes by "not on origin" (the page prune) must then delete nothing."""
1961
+ branch = fetch(root)
1962
+ tree = _git(root, "ls-tree", "--name-only", f"origin/{branch}", ".caddis/roadmaps/", check=False)
1963
+ folders: set[str] | None = set() if tree.returncode == 0 else None
1964
+ slugs: list[str] = []
1965
+ for folder in tree.stdout.splitlines():
1966
+ folder = folder.strip().rstrip("/")
1967
+ if not folder:
1968
+ continue
1969
+ if folders is not None:
1970
+ folders.add(folder.rsplit("/", 1)[-1])
1971
+ if _git(root, "cat-file", "-e", f"origin/{branch}:{folder}/roadmap.md", check=False).returncode == 0:
1972
+ slugs.append(folder.rsplit("/", 1)[-1])
1973
+ return sorted(slugs), folders
1974
+
1975
+
1976
+ def _build_parser() -> argparse.ArgumentParser:
1977
+ parser = _Parser(description=__doc__.splitlines()[0])
1978
+ sub = parser.add_subparsers(dest="cmd", required=True)
1979
+
1980
+ def add(name: str, write: bool = False, json_out: bool = False) -> argparse.ArgumentParser:
1981
+ p = sub.add_parser(name)
1982
+ p.add_argument("--roadmap", required=True)
1983
+ p.add_argument("--root", default=".")
1984
+ if json_out:
1985
+ p.add_argument("--json", action="store_true")
1986
+ if write:
1987
+ p.add_argument("--dry-run", action="store_true", help="print the diff and commands; run and write nothing")
1988
+ return p
1989
+
1990
+ p = sub.add_parser("list")
1991
+ p.add_argument("--root", default=".")
1992
+ add("status", json_out=True)
1993
+ add("next", json_out=True)
1994
+ add("check")
1995
+ p = add("set", write=True)
1996
+ p.add_argument("--id", required=True)
1997
+ p.add_argument("--col", required=True)
1998
+ p.add_argument("--value", required=True)
1999
+ p.add_argument("--by-owner", action="store_true")
2000
+ p.add_argument("--reason", default=None,
2001
+ help="for Status not-needed: \"dropped: <why>\" or .caddis/parking-lot/<file>.md (on origin)")
2002
+ for name in ("block", "unblock"):
2003
+ p = add(name, write=True)
2004
+ p.add_argument("--id", required=True)
2005
+ p.add_argument("--type", required=True)
2006
+ p.add_argument("--reason", required=True)
2007
+ if name == "block":
2008
+ p.add_argument("--check", default="")
2009
+ else:
2010
+ p.add_argument("--check", default=None, help="the row's Check text, when several rows share a reason")
2011
+ p.add_argument("--by-owner", action="store_true")
2012
+ p = add("rollup", write=True)
2013
+ p.add_argument("--id", required=True)
2014
+ add("recheck", write=True)
2015
+ p = sub.add_parser("render")
2016
+ which = p.add_mutually_exclusive_group(required=True)
2017
+ which.add_argument("--roadmap")
2018
+ which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
2019
+ p.add_argument("--root", default=".")
2020
+ p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
2021
+ return parser
2022
+
2023
+
2024
+ def _dispatch(args: argparse.Namespace) -> int:
2025
+ root = _repo_root(args.root)
2026
+ if args.cmd == "list":
2027
+ slugs = list_roadmaps(root)
2028
+ for slug in slugs:
2029
+ print(slug)
2030
+ return caddis_exit.CLEAN if slugs else caddis_exit.BLOCKED
2031
+ if args.cmd == "status":
2032
+ result = status(root, args.roadmap)
2033
+ print(json.dumps(result, indent=2) if args.json else result["line"])
2034
+ return caddis_exit.CLEAN
2035
+ if args.cmd == "next":
2036
+ batch, reasons = _select(load(root, args.roadmap), other_roadmap_batches(root, args.roadmap))
2037
+ if args.json:
2038
+ print(json.dumps({"batch": batch, "skipped": [{"id": i, "reason": r} for i, r in reasons]}))
2039
+ else:
2040
+ for mid, reason in reasons:
2041
+ print(f"{mid}: {reason}")
2042
+ print(f"next: {batch['milestone']} {batch['plan']}#{batch['phase']}" if batch
2043
+ else "BLOCKED: no milestone is ready")
2044
+ return caddis_exit.CLEAN if batch else caddis_exit.BLOCKED
2045
+ if args.cmd == "check":
2046
+ errors, warnings = check(root, args.roadmap)
2047
+ for w in warnings:
2048
+ print(f"WARNING: {w}")
2049
+ for e in errors:
2050
+ print(f"ERROR: {e}")
2051
+ print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
2052
+ return caddis_exit.BLOCKED if errors else caddis_exit.CLEAN
2053
+ if args.cmd == "set":
2054
+ return set_cell(root, args.roadmap, args.id, args.col, args.value, args.by_owner, args.dry_run,
2055
+ reason=args.reason)
2056
+ if args.cmd == "block":
2057
+ return block(root, args.roadmap, args.id, args.type, args.reason, args.check, args.by_owner, args.dry_run)
2058
+ if args.cmd == "unblock":
2059
+ return unblock(root, args.roadmap, args.id, args.type, args.reason, args.by_owner, args.dry_run,
2060
+ check_cmd=args.check)
2061
+ if args.cmd == "rollup":
2062
+ return rollup(root, args.roadmap, args.id, args.dry_run)
2063
+ if args.cmd == "recheck":
2064
+ return recheck(root, args.roadmap, args.dry_run)
2065
+ if args.cmd == "render":
2066
+ import caddis_roadmap_page # the page is separate; only `render` needs it
2067
+ if args.all and args.out:
2068
+ raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
2069
+ slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2070
+ failed = written = pruned = 0
2071
+ for slug in slugs: # --all: one bad roadmap must not stop the others
2072
+ try:
2073
+ path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2074
+ sys.modules[__name__])
2075
+ written += 1
2076
+ print(f"[ROADMAP] page written: {path.resolve()}")
2077
+ except Exception as exc: # --all: one roadmap must never stop the others
2078
+ if not args.all:
2079
+ raise
2080
+ failed += 1
2081
+ print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2082
+ try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2083
+ if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2084
+ # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2085
+ pruned = caddis_roadmap_page.prune_folder(
2086
+ caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2087
+ if not args.out and (written or pruned):
2088
+ print(f"[ROADMAP] hub written: {caddis_roadmap_page.write_hub().resolve()}")
2089
+ except Exception as exc:
2090
+ print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2091
+ return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2092
+ return caddis_exit.ERROR
2093
+
2094
+
2095
+ def main(argv: list[str] | None = None) -> int:
2096
+ args = _build_parser().parse_args(argv)
2097
+ try:
2098
+ return _dispatch(args)
2099
+ except RoadmapError as exc:
2100
+ print(f"{caddis_exit.MEANING.get(exc.code, 'error').upper()}: {exc}", file=sys.stderr)
2101
+ return exc.code
2102
+ except Exception as exc: # an unexpected failure is an error, never "blocked"
2103
+ print(f"ERROR: unexpected {type(exc).__name__}: {str(exc).splitlines()[0] if str(exc) else ''}",
2104
+ file=sys.stderr)
2105
+ return caddis_exit.ERROR
2106
+
2107
+
2108
+ if __name__ == "__main__":
2109
+ sys.exit(main())