@caddis/cli 0.4.52 → 0.4.53

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