@caddis/cli 0.4.75 → 0.4.77

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.
@@ -37,6 +37,7 @@ looks like an edit/write and whose args carry a path. Unknown tools -> allow.
37
37
  """
38
38
  from __future__ import annotations
39
39
 
40
+ import fnmatch
40
41
  import json
41
42
  import math
42
43
  import os
@@ -78,6 +79,9 @@ def classify_write(file_path: str) -> tuple[str, str]:
78
79
  return "deny", f"writes an environment/secret file ({base})"
79
80
  if base in _SECRET_BASENAMES or any(base.endswith(s) for s in _SECRET_SUFFIXES):
80
81
  return "deny", f"writes a credential/key file ({base})"
82
+ why = hub_dir_reason(file_path)
83
+ if why:
84
+ return "deny", why
81
85
 
82
86
  why = guard_config_reason(file_path)
83
87
  if why:
@@ -126,6 +130,114 @@ def guard_config_reason(path: str) -> str:
126
130
  return ""
127
131
 
128
132
 
133
+ # ── the hub folder (hub design 7a) ───────────────────────────────────────────
134
+ # `~/.caddis/hub/**` (and `$CADDIS_HOME/.caddis/hub/**` when CADDIS_HOME is set) holds the hub key, the repo registry,
135
+ # the owner requests and the audit log. An agent that writes there could forge an owner request, so a write by tool or
136
+ # through the shell (redirection, tee, cp/mv, Set-Content, Out-File, rm, Remove-Item, ...) denies. Reads stay free.
137
+ # A run of `python .../caddis_coordinate.py` stays free: the script writes, not a shell redirection. Known limits (it
138
+ # is not a boundary): an interpreter one-liner, a script that writes the file, a variable other than the home ones.
139
+ # Copied from hooks/guard.py; test_guard_agy.py checks they agree.
140
+ _WHY_HUB = "writes the caddis hub folder (~/.caddis/hub); only the hub and caddis_coordinate.py write there"
141
+ # CADDIS_HOME as a shell spells it. It becomes the marker below before the tokenizer splits the text on `$`.
142
+ _CADDIS_HOME_VAR = re.compile(r"\$\{?env:caddis_home\}?|\$\{caddis_home\}|\$caddis_home\b|%caddis_home%", re.I)
143
+ _CADDIS_HOME_MARK = "~caddis-home"
144
+ _HUB_DELETERS = frozenset({"rm", "del", "erase", "rmdir", "rd", "unlink", "shred", "truncate", "remove-item", "ri",
145
+ "clear-content", "clc"})
146
+
147
+
148
+ def _hub_dirs() -> list[str]:
149
+ """The hub folders to protect: under the user home, and under CADDIS_HOME when it is set."""
150
+ homes = [os.path.expanduser("~")]
151
+ if os.environ.get("CADDIS_HOME"):
152
+ homes.append(os.environ["CADDIS_HOME"])
153
+ return [os.path.normcase(os.path.realpath(os.path.join(h, _ARTIFACT_DIR, "hub"))) for h in homes]
154
+
155
+
156
+ # A device prefix that Windows removes before it opens the path: `\\?\`, `\\.\`, `\\?\UNC\`, `\??\` (either slash).
157
+ _HUB_DEVICE_PREFIX = re.compile(r"^[\\/]{2}[?.][\\/](?:unc[\\/])?|^[\\/]\?\?[\\/]", re.I)
158
+ _HUB_GLOB = re.compile(r"[*?\[]")
159
+ # A `$` just before a separator is never a variable: an admin share (`\\localhost\c$\...`). The tokenizer
160
+ # splits on `$`, so the target finders drop it first.
161
+ _SHARE_DOLLAR = re.compile(r"\$(?=[\\/])")
162
+
163
+
164
+ def _hub_clean(raw: str) -> str:
165
+ """``raw`` spelled the way Windows opens it, with `/` separators: no device prefix, each segment cut at its first
166
+ `:` (an NTFS stream such as `::$DATA` or `::$INDEX_ALLOCATION`; a drive letter stays), and trailing dots and
167
+ spaces removed from each segment (`hub.` and `hub ` open `hub`)."""
168
+ raw = _HUB_DEVICE_PREFIX.sub("", raw)
169
+ lead = re.match(r"[\\/]*", raw).group(0)
170
+ segs = []
171
+ for i, seg in enumerate(re.split(r"[\\/]+", raw[len(lead):])):
172
+ drive = seg[:2] if i == 0 and not lead and re.match(r"[A-Za-z]:", seg) else ""
173
+ seg = drive + seg[len(drive):].split(":", 1)[0]
174
+ trimmed = seg.rstrip(" .")
175
+ segs.append(trimmed if trimmed or seg.strip(" ") not in (".", "..") else seg.strip(" "))
176
+ return lead.replace("\\", "/") + "/".join(segs)
177
+
178
+
179
+ def _hub_parts(clean: str) -> list[str]:
180
+ """The lower-case segments of a _hub_clean path, with `.` and `..` applied the way Windows does (by text)."""
181
+ out: list[str] = []
182
+ for seg in clean.lower().split("/"):
183
+ if seg == "..":
184
+ out = out[:-1]
185
+ elif seg and seg != ".":
186
+ out.append(seg)
187
+ return out
188
+
189
+
190
+ def _hub_seg_is(seg: str, name: str, shell: bool) -> bool:
191
+ """``seg`` names ``name``. In a shell, a glob segment (`hu*`, `h?b`, `[h]ub`) names it when it matches; as in
192
+ bash, only a glob that starts with `.` matches a dot name."""
193
+ if seg == name:
194
+ return True
195
+ return (shell and bool(_HUB_GLOB.search(seg)) and (seg.startswith(".") or not name.startswith("."))
196
+ and fnmatch.fnmatchcase(name, seg))
197
+
198
+
199
+ def _hub_spellings(raw: str, shell: bool) -> list[str]:
200
+ """The paths a shell can open for ``raw``: quotes removed, then `\\` read as a separator (cmd, PowerShell) and
201
+ `\\` or a backtick read as an escape (bash, PowerShell). A tool path is opened as it is."""
202
+ if not shell:
203
+ return [raw]
204
+ unquoted = raw.replace('"', "").replace("'", "")
205
+ return [unquoted, re.sub(r"[\\`](.)", r"\1", unquoted)]
206
+
207
+
208
+ def hub_dir_reason(path: str, shell: bool = False) -> str:
209
+ """The deny reason when ``path`` is the hub folder or a file in it, else ''. ``shell`` is True for a path that a
210
+ shell reads (quotes, escapes and globs apply). A path with a `.caddis` segment and then a `hub` segment denies
211
+ whatever its root (a device prefix, an admin share such as `\\\\localhost\\c$`, another home): the backstop."""
212
+ if not isinstance(path, str):
213
+ return ""
214
+ raw = _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, path.strip().strip("'\""))
215
+ for spelling in _hub_spellings(raw, shell):
216
+ clean = _hub_clean(_HOME_VAR.sub("~", spelling))
217
+ parts = _hub_parts(clean)
218
+ hubs = [i for i, seg in enumerate(parts) if _hub_seg_is(seg, "hub", shell)]
219
+ if any(i and _hub_seg_is(parts[i - 1], _ARTIFACT_DIR, shell) for i in hubs):
220
+ return _WHY_HUB
221
+ if not hubs or clean.startswith("//"): # cheap test first; never resolve a UNC path (a network call)
222
+ continue
223
+ if clean.lower().startswith(_CADDIS_HOME_MARK):
224
+ clean = (os.environ.get("CADDIS_HOME") or "~") + clean[len(_CADDIS_HOME_MARK):]
225
+ full = os.path.normcase(os.path.realpath(os.path.abspath(os.path.normpath(_home_path(clean)))))
226
+ for hub in _hub_dirs():
227
+ if full == hub or full.startswith(hub.rstrip(os.sep) + os.sep):
228
+ return _WHY_HUB
229
+ return ""
230
+
231
+
232
+ def _hub_delete_targets(command: str) -> list[str]:
233
+ """The paths that rm / del / Remove-Item / Clear-Content name in ``command`` (their non-option words)."""
234
+ command = _SHARE_DOLLAR.sub("", _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, _HOME_VAR.sub("~", command)))
235
+ out: list[str] = []
236
+ for _name, _cwd, _sub, args in _tool_calls(command, None, _HUB_DELETERS):
237
+ out += [a for a in args if not a.startswith(("-", ">", "<"))] # cmd flags (`/s`) never name the hub
238
+ return out
239
+
240
+
129
241
  # Copied from hooks/guard.py: the Read rule, used by the shell secret-read rule below.
130
242
  def classify_read(file_path: str) -> tuple[str, str]:
131
243
  """Risk tier (deny|allow) + reason for reading ``file_path`` via the Read tool.
@@ -483,7 +595,8 @@ def _shell_write_targets(command: str) -> list[str]:
483
595
  targets of the writer programs the config rule knows (cp, mv, Copy-Item, Set-Content, New-Item, Tee-Object, sed -i,
484
596
  ...). Not the regex's `-Path` group: `Get-Content -Path x` reads. Best-effort by design: a miss degrades to the old
485
597
  behaviour, it never invents a denial. guard_agy.py carries a copy; test_guard_agy.py checks they agree."""
486
- command = _HOME_VAR.sub("~", command) # the tokenizer splits on `$`, so in `$HOME/x` the home part would be lost
598
+ # The tokenizer splits on `$`, so in `$HOME/x` the home part would be lost.
599
+ command = _SHARE_DOLLAR.sub("", _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, _HOME_VAR.sub("~", command)))
487
600
  out = [m.group(g).strip("'\"") for m in _SHELL_WRITE_TARGET.finditer(command)
488
601
  for g in ("redir", "tee", "outfile") if m.group(g)]
489
602
  try:
@@ -556,6 +669,18 @@ def _copier_targets(args: list[str]) -> list[str]:
556
669
  return [dest] + [os.path.join(dest, os.path.basename(src.replace("\\", "/"))) for src in sources]
557
670
 
558
671
 
672
+ def _hub_delete_safe(text: str) -> str:
673
+ """The deny reason when a delete command (rm, Remove-Item, ...) names the hub folder, else ''. An error is
674
+ "no match": one rule must never switch off the rest."""
675
+ try:
676
+ for target in _hub_delete_targets(text):
677
+ if hub_dir_reason(target, shell=True):
678
+ return f"shell command deletes in the caddis hub folder (~/.caddis/hub); {_WHY_HUB.split('; ', 1)[1]}"
679
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
680
+ return ""
681
+ return ""
682
+
683
+
559
684
  def guard_config_shell_reason(command: str) -> str:
560
685
  """The ask reason when a shell command writes a `.caddis/config.toml` or `~/.caddis/*.toml`, else ''.
561
686
  Callers pass heredoc-free text and treat an exception as "no match"."""
@@ -1181,9 +1306,14 @@ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
1181
1306
  # ── deny: writing a secret through the shell (copied from hooks/guard.py) ──
1182
1307
  # The same file must not be denied via a write tool and waved through via `echo ... > .env`.
1183
1308
  for _target in _shell_write_targets(data_free):
1309
+ if hub_dir_reason(_target, shell=True): # the shell reads quotes, escapes and globs; a tool path does not
1310
+ return "deny", f"shell command {_WHY_HUB}"
1184
1311
  _tier, _why = classify_write(_target)
1185
1312
  if _tier == "deny":
1186
1313
  return "deny", f"shell command {_why}"
1314
+ _hub = _hub_delete_safe(data_free)
1315
+ if _hub:
1316
+ return "deny", _hub
1187
1317
 
1188
1318
  # ── deny: an inline python/node/etc. one-liner that touches a secret env file ──
1189
1319
  if _INLINE_ENV.search(c):
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.161",
3
+ "version": "1.3.163",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Batched hub collector: every repo's roadmaps, plans and relays, plus agy spend and the guide, as one JSON object.
3
+
4
+ Usage: caddis_hub_collect.py --repos PATH [PATH ...] [--only roadmaps,plans,relays,spend,guide] --json
5
+
6
+ The hub runs this once per cycle. It runs in ONE Python process for all repos (hub rule R15, spawn cost L8). It
7
+ never calls gh, claude or agy, and never runs `git fetch`: the roadmap reader sees the origin refs the repo
8
+ already has. The parsers stay in the scripts that own each format (R14); this file only calls them.
9
+
10
+ Output: {"schema": 1, "generated_at": "<UTC Z>", "repos": [{"path", "fingerprint", "roadmaps", "plans",
11
+ "relays"}], "spend": ..., "guide": ...}. A path that is not a folder gives {"path", "error"}. A source that fails
12
+ gives {"error": "<one line>"} in its slot. The run still exits 0. Exit 1 is for bad arguments only.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import importlib
19
+ import json
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ SCHEMA = 1
25
+ KINDS = ("roadmaps", "plans", "relays", "spend", "guide")
26
+ _REPO_KINDS = ("roadmaps", "plans", "relays")
27
+
28
+
29
+ def _sibling(name: str):
30
+ """A sibling script as a module. The same module object the rest of the process uses."""
31
+ try:
32
+ return importlib.import_module(name)
33
+ except ModuleNotFoundError as exc:
34
+ if exc.name != name:
35
+ raise
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ return importlib.import_module(name)
38
+
39
+
40
+ def _error(exc: BaseException) -> dict:
41
+ lines = str(exc).strip().splitlines()
42
+ return {"error": lines[0] if lines else type(exc).__name__}
43
+
44
+
45
+ def _roadmaps_json(root: Path) -> dict:
46
+ module = _sibling("caddis_roadmap")
47
+ saved = module.FETCH
48
+ module.FETCH = False # R15: no network git. Read the origin refs the repo already has.
49
+ try:
50
+ return module.roadmaps_json(root)
51
+ finally:
52
+ module.FETCH = saved
53
+
54
+
55
+ def _plans_json(root: Path) -> dict:
56
+ return _sibling("caddis_todo").plans_json(root)
57
+
58
+
59
+ def _relays_json(root: Path) -> dict:
60
+ return _sibling("caddis_workstreams").relays_json(root)
61
+
62
+
63
+ def _spend_json() -> dict:
64
+ return _sibling("caddis_spend").spend_json(Path.home())
65
+
66
+
67
+ def _guide_json() -> dict:
68
+ return _sibling("build_reference").guide_json()
69
+
70
+
71
+ _REPO_COLLECTORS = {"roadmaps": _roadmaps_json, "plans": _plans_json, "relays": _relays_json}
72
+
73
+
74
+ _REPARSE_POINT = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT: a junction or a symlink on Windows
75
+
76
+
77
+ def _is_link(path: Path) -> bool:
78
+ """True for a symlink or a Windows junction. Python 3.11 `is_symlink()` is False for a junction."""
79
+ try:
80
+ info = path.lstat()
81
+ except OSError:
82
+ return False # not there: nothing to follow
83
+ return path.is_symlink() or bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT)
84
+
85
+
86
+ def _files(folder: Path, pattern: str = "*", recurse: bool = False) -> list[Path]:
87
+ """The plain files in `folder`. Never enter a linked folder and never list a linked file (stat only)."""
88
+ if not folder.is_dir() or _is_link(folder):
89
+ return []
90
+ found: list[Path] = []
91
+ for path in folder.iterdir():
92
+ if _is_link(path):
93
+ continue
94
+ if path.is_dir():
95
+ if recurse:
96
+ found.extend(_files(path, pattern, recurse))
97
+ elif path.is_file() and path.match(pattern):
98
+ found.append(path)
99
+ return found
100
+
101
+
102
+ def _input_files(repo: Path) -> list[Path]:
103
+ """The files the repo's collectors read from the working tree."""
104
+ artifact = repo / ".caddis"
105
+ if _is_link(artifact):
106
+ return []
107
+ files = _files(artifact / "roadmaps", recurse=True)
108
+ plans = artifact / "plans"
109
+ if not plans.is_dir():
110
+ plans = repo / ".github" / "plans" # the legacy folder, as caddis_todo reads it
111
+ files.extend(_files(plans, "*.md"))
112
+ files.extend(_files(artifact / "relay", "*.md"))
113
+ return files
114
+
115
+
116
+ def git_common_dir(repo: Path) -> Path | None:
117
+ """The git common dir, found with no subprocess: a `.git` folder, or a `.git` file (a worktree) whose `gitdir:`
118
+ folder has a `commondir` file. None when the repo has no readable git dir."""
119
+ dot_git = repo / ".git"
120
+ if _is_link(dot_git):
121
+ return None
122
+ if dot_git.is_dir():
123
+ return dot_git
124
+ try:
125
+ line = dot_git.read_text(encoding="utf-8").strip()
126
+ except (OSError, UnicodeDecodeError):
127
+ return None
128
+ if not line.startswith("gitdir:"):
129
+ return None
130
+ gitdir = Path(line[len("gitdir:"):].strip())
131
+ if not gitdir.is_absolute():
132
+ gitdir = repo / gitdir
133
+ try:
134
+ common = (gitdir / "commondir").read_text(encoding="utf-8").strip()
135
+ except (OSError, UnicodeDecodeError):
136
+ return gitdir if gitdir.is_dir() else None
137
+ path = Path(common)
138
+ return path if path.is_absolute() else gitdir / path
139
+
140
+
141
+ def _origin_ref_files(common: Path) -> list[Path]:
142
+ """The ref files the roadmap reader gets its data from: packed-refs, origin/HEAD, every origin branch."""
143
+ files = [p for p in (common / "packed-refs",) if p.is_file() and not _is_link(p)]
144
+ # refs/remotes/origin/HEAD sits inside refs/remotes/origin/, so the walk covers it.
145
+ return files + _files(common / "refs" / "remotes" / "origin", recurse=True)
146
+
147
+
148
+ def fingerprint(repo: Path) -> str:
149
+ """sha256 over the sorted (name, mtime_ns, size) of every input file and origin ref file. Empty list: still a hash.
150
+
151
+ The roadmaps come from the origin refs (`git show origin/<default>:...`), so a fetch must change the hash."""
152
+ repo = Path(repo)
153
+ named = [(path.relative_to(repo).as_posix(), path) for path in _input_files(repo)]
154
+ common = git_common_dir(repo)
155
+ if common is not None:
156
+ named.extend((f"git:{path.relative_to(common).as_posix()}", path) for path in _origin_ref_files(common))
157
+ rows = []
158
+ for name, path in named:
159
+ try:
160
+ info = path.stat()
161
+ except OSError:
162
+ continue # the file went away while we looked
163
+ rows.append((name, info.st_mtime_ns, info.st_size))
164
+ return hashlib.sha256(json.dumps(sorted(rows)).encode("utf-8")).hexdigest()
165
+
166
+
167
+ def collect_repo(path: str, kinds: tuple[str, ...]) -> dict:
168
+ repo = Path(path)
169
+ if not repo.is_dir():
170
+ return {"path": path, "error": "not a folder"}
171
+ entry: dict = {"path": path}
172
+ try:
173
+ entry["fingerprint"] = fingerprint(repo)
174
+ except Exception as exc: # a repo we cannot read must not stop the others
175
+ return {"path": path, **_error(exc)}
176
+ for kind in _REPO_KINDS:
177
+ if kind not in kinds:
178
+ continue
179
+ try:
180
+ entry[kind] = _REPO_COLLECTORS[kind](repo)
181
+ except Exception as exc:
182
+ entry[kind] = _error(exc)
183
+ return entry
184
+
185
+
186
+ def collect(repos: list[str], kinds: tuple[str, ...]) -> dict:
187
+ out: dict = {"schema": SCHEMA, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
188
+ "repos": [collect_repo(path, kinds) for path in repos]}
189
+ for kind, call in (("spend", _spend_json), ("guide", _guide_json)):
190
+ if kind not in kinds:
191
+ continue
192
+ try:
193
+ out[kind] = call()
194
+ except Exception as exc:
195
+ out[kind] = _error(exc)
196
+ return out
197
+
198
+
199
+ class _Parser(argparse.ArgumentParser):
200
+ def error(self, message: str): # bad arguments exit 1, not argparse's 2
201
+ self.print_usage(sys.stderr)
202
+ print(f"caddis-hub-collect: {message}", file=sys.stderr)
203
+ raise SystemExit(1)
204
+
205
+
206
+ def main(argv: list[str] | None = None) -> int:
207
+ for stream in (sys.stdout, sys.stderr):
208
+ if hasattr(stream, "reconfigure"):
209
+ stream.reconfigure(encoding="utf-8", errors="replace")
210
+ parser = _Parser(description=__doc__.splitlines()[0])
211
+ parser.add_argument("--repos", nargs="+", required=True, metavar="PATH")
212
+ parser.add_argument("--only", default=",".join(KINDS), help="comma list of: " + ", ".join(KINDS))
213
+ parser.add_argument("--json", action="store_true", required=True)
214
+ args = parser.parse_args(argv)
215
+ kinds = tuple(k.strip() for k in args.only.split(",") if k.strip())
216
+ unknown = [k for k in kinds if k not in KINDS]
217
+ if unknown or not kinds:
218
+ parser.error(f"unknown --only kind: {', '.join(unknown) or '(empty)'}; use {', '.join(KINDS)}")
219
+ print(json.dumps(collect(args.repos, kinds), ensure_ascii=False))
220
+ return 0
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
@@ -49,7 +49,8 @@ Exit codes (caddis_exit): 0 clean, 1 blocked (nothing ready, check failing, PR n
49
49
  (no origin, no roadmap, lock busy, an exit check or Exit setup that cannot run in the merged checkout), 4 error
50
50
  (malformed roadmap, unknown id, column or value, refused change).
51
51
 
52
- Usage: caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
52
+ Usage: caddis_roadmap.py list [--root DIR] [--json]
53
+ caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
53
54
  caddis_roadmap.py next --roadmap SLUG [--root DIR] [--json]
54
55
  caddis_roadmap.py check --roadmap SLUG [--root DIR]
55
56
  caddis_roadmap.py set --roadmap SLUG [--root DIR] --id ID --col Status|Owner|Target --value V [--by-owner]
@@ -198,6 +199,9 @@ DROPPED_PREFIX = "dropped:"
198
199
  OLD_BLOCKER_DAYS = 14 # status lists a blocker whose row is older than this
199
200
 
200
201
 
202
+ JSON_SCHEMA = 1 # the `schema` key of every --json output (hub rule R13)
203
+
204
+
201
205
  class RoadmapError(Exception):
202
206
  """A refused or impossible request; carries the caddis exit code."""
203
207
 
@@ -237,11 +241,21 @@ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProc
237
241
 
238
242
 
239
243
  def default_branch(root: Path) -> str:
240
- """origin/HEAD's branch; when it is not set locally, ask the remote (`git ls-remote --symref origin HEAD`)."""
244
+ """origin/HEAD's branch; when it is not set locally, ask the remote (`git ls-remote --symref origin HEAD`).
245
+ When FETCH is off, never ask the remote: use origin/main or origin/master if exactly one exists locally."""
241
246
  proc = _git(root, "symbolic-ref", "refs/remotes/origin/HEAD", check=False)
242
247
  ref = proc.stdout.strip()
243
248
  if proc.returncode == 0 and ref.startswith("refs/remotes/origin/"):
244
249
  return ref[len("refs/remotes/origin/"):]
250
+ if not FETCH:
251
+ # No network when fetch is off (the collector, `render --no-fetch`): use the one local origin branch.
252
+ found = [name for name in ("main", "master")
253
+ if _git(root, "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{name}^{{commit}}",
254
+ check=False).returncode == 0]
255
+ if len(found) == 1:
256
+ return found[0]
257
+ raise RoadmapError("cannot tell origin's default branch: origin/HEAD is not set locally; "
258
+ "run: git remote set-head origin --auto", caddis_exit.NOT_RUN)
245
259
  remote = _git(root, "ls-remote", "--symref", "origin", "HEAD", check=False)
246
260
  for line in remote.stdout.splitlines():
247
261
  match = re.match(r"^ref:\s*refs/heads/(\S+)\s+HEAD$", line)
@@ -2190,6 +2204,19 @@ def list_roadmaps_checked(root: Path) -> tuple[list[str], set[str] | None]:
2190
2204
  return sorted(slugs), folders
2191
2205
 
2192
2206
 
2207
+ def roadmaps_json(root: Path) -> dict:
2208
+ """The hub's roadmap list: status() of each slug, in `list` order. A roadmap whose status() raises gives
2209
+ {"roadmap": slug, "error": "<one line>"} and the rest still list. Never prints, never exits."""
2210
+ entries: list[dict] = []
2211
+ for slug in list_roadmaps(Path(root)):
2212
+ try:
2213
+ entries.append(status(Path(root), slug))
2214
+ except Exception as exc: # one broken file must not hide the others
2215
+ lines = str(exc).strip().splitlines()
2216
+ entries.append({"roadmap": slug, "error": lines[0] if lines else type(exc).__name__})
2217
+ return {"schema": JSON_SCHEMA, "roadmaps": entries}
2218
+
2219
+
2193
2220
  def _build_parser() -> argparse.ArgumentParser:
2194
2221
  parser = _Parser(description=__doc__.splitlines()[0])
2195
2222
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -2206,6 +2233,7 @@ def _build_parser() -> argparse.ArgumentParser:
2206
2233
 
2207
2234
  p = sub.add_parser("list")
2208
2235
  p.add_argument("--root", default=".")
2236
+ p.add_argument("--json", action="store_true")
2209
2237
  add("status", json_out=True)
2210
2238
  add("next", json_out=True)
2211
2239
  add("check")
@@ -2371,6 +2399,10 @@ def _render_site(root: Path, args: argparse.Namespace) -> int:
2371
2399
 
2372
2400
  def _dispatch(args: argparse.Namespace) -> int:
2373
2401
  root = _repo_root(args.root)
2402
+ if args.cmd == "list" and args.json:
2403
+ data = roadmaps_json(root)
2404
+ print(json.dumps(data, indent=2))
2405
+ return caddis_exit.CLEAN if data["roadmaps"] else caddis_exit.BLOCKED
2374
2406
  if args.cmd == "list":
2375
2407
  slugs = list_roadmaps(root)
2376
2408
  for slug in slugs:
@@ -2378,12 +2410,13 @@ def _dispatch(args: argparse.Namespace) -> int:
2378
2410
  return caddis_exit.CLEAN if slugs else caddis_exit.BLOCKED
2379
2411
  if args.cmd == "status":
2380
2412
  result = status(root, args.roadmap)
2381
- print(json.dumps(result, indent=2) if args.json else result["line"])
2413
+ print(json.dumps({"schema": JSON_SCHEMA, **result}, indent=2) if args.json else result["line"])
2382
2414
  return caddis_exit.CLEAN
2383
2415
  if args.cmd == "next":
2384
2416
  batch, reasons = _select(load(root, args.roadmap), other_roadmap_batches(root, args.roadmap))
2385
2417
  if args.json:
2386
- print(json.dumps({"batch": batch, "skipped": [{"id": i, "reason": r} for i, r in reasons]}))
2418
+ skipped = [{"id": i, "reason": r} for i, r in reasons]
2419
+ print(json.dumps({"schema": JSON_SCHEMA, "batch": batch, "skipped": skipped}))
2387
2420
  else:
2388
2421
  for mid, reason in reasons:
2389
2422
  print(f"{mid}: {reason}")
@@ -493,6 +493,19 @@ def status_line(home: Path, now: datetime) -> str:
493
493
  return f"agy: EUR {committed_last_24h(home, now):.2f} of {cap} in the last 24 h on this machine"
494
494
 
495
495
 
496
+ def spend_json(home: Path, now: datetime | None = None) -> dict:
497
+ """The spend status as one JSON-ready dict. It holds no key, no prompt and no caller name."""
498
+ now = now or datetime.now(timezone.utc)
499
+ try:
500
+ cap = load_cap(home)
501
+ cap_eur, reserve_eur = float(cap.eur_per_24h), float(cap.reserve)
502
+ except NotConfigured:
503
+ cap_eur = reserve_eur = None
504
+ return {"schema": 1, "configured": cap_eur is not None, "cap_eur": cap_eur,
505
+ "committed_eur": round(committed_last_24h(home, now), 6), "reserve_eur": reserve_eur,
506
+ "window_h": 24}
507
+
508
+
496
509
  def main(argv: list[str] | None = None) -> int:
497
510
  if hasattr(sys.stdout, "reconfigure"):
498
511
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -504,7 +517,8 @@ def main(argv: list[str] | None = None) -> int:
504
517
  parser = argparse.ArgumentParser(description="global agy spend cap (EUR per rolling 24 h, this machine)")
505
518
  sub = parser.add_subparsers(dest="cmd", required=True)
506
519
  sub.add_parser("check")
507
- sub.add_parser("status")
520
+ p_status = sub.add_parser("status")
521
+ p_status.add_argument("--json", action="store_true", help="print the status as one JSON object")
508
522
  p_run = sub.add_parser("run")
509
523
  p_run.add_argument("--caller", required=True)
510
524
  p_run.add_argument("--timeout-s", type=float, default=None,
@@ -515,7 +529,7 @@ def main(argv: list[str] | None = None) -> int:
515
529
  now = datetime.now(timezone.utc)
516
530
  if args.cmd == "status":
517
531
  try:
518
- print(status_line(home, now))
532
+ print(json.dumps(spend_json(home, now)) if args.json else status_line(home, now))
519
533
  except Exception as exc:
520
534
  print(f"caddis_spend: could not read the spend log: {exc!r}", file=sys.stderr)
521
535
  return EXIT_NOT_CONFIGURED
@@ -55,6 +55,7 @@ CADDIS_TEMP_PREFIXES = {
55
55
  "caddis-codex-": "cli/test/codex.test.ts mkdtempSync",
56
56
  "caddis-doctor-home-": "cli/test/doctor.test.ts mkdtempSync",
57
57
  "caddis-doctor-sessions-": "cli/test/doctor.test.ts mkdtempSync",
58
+ "caddis-hub-": "cli/test/hub/*.test.ts mkdtempSync",
58
59
  "caddis-keys-": "cli/test/providers.test.ts mkdtempSync",
59
60
  "caddis-sessions-": "cli/test/sessions.test.ts mkdtempSync",
60
61
  "caddis-stub-": "cli/test/stub.test.ts mkdtempSync",
@@ -9,6 +9,7 @@ release as a no-op, because an older /handoff still calls it from its Step 6.
9
9
  from __future__ import annotations
10
10
 
11
11
  import argparse
12
+ import json
12
13
  import os
13
14
  import re
14
15
  import subprocess
@@ -17,7 +18,7 @@ from pathlib import Path
17
18
 
18
19
  try:
19
20
  import caddis_exit
20
- from caddis_frontmatter import parse
21
+ from caddis_frontmatter import parse, split_document
21
22
  from caddis_workstreams import _artifact, _name
22
23
  except ModuleNotFoundError as exc:
23
24
  if exc.name not in {"caddis_exit", "caddis_file_lock", "caddis_frontmatter", "caddis_workstreams"}:
@@ -26,7 +27,7 @@ except ModuleNotFoundError as exc:
26
27
  sys.path.insert(0, str(Path(__file__).resolve().parent))
27
28
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
28
29
  import caddis_exit
29
- from caddis_frontmatter import parse
30
+ from caddis_frontmatter import parse, split_document
30
31
  from caddis_workstreams import _artifact, _name
31
32
 
32
33
 
@@ -172,6 +173,63 @@ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
172
173
  return columns, table[:at] + table[at + 1:]
173
174
 
174
175
 
176
+ def _plan_files(root: Path) -> list[Path]:
177
+ """The plan files to list: `.caddis/plans/*.md`, else the legacy `.github/plans/*.md`. Never `done/`."""
178
+ plans_dir = _artifact(root) / "plans"
179
+ if not plans_dir.is_dir():
180
+ plans_dir = Path(root) / ".github" / "plans"
181
+ if plans_dir.is_symlink() or not plans_dir.is_dir():
182
+ return []
183
+ return sorted(p for p in plans_dir.glob("*.md") if p.is_file() and not p.is_symlink())
184
+
185
+
186
+ def _plan_entry(root: Path, path: Path) -> dict:
187
+ # caddis_tidy imports this module at load time, so this module imports it only here.
188
+ try:
189
+ import caddis_tidy
190
+ except ModuleNotFoundError as exc:
191
+ if exc.name != "caddis_tidy":
192
+ raise
193
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
194
+ import caddis_tidy
195
+ text = path.read_text(encoding="utf-8")
196
+ meta, body = split_document(text)
197
+ if text.lstrip("").startswith("---") and not meta:
198
+ raise ValueError("frontmatter is not closed or has no keys")
199
+ heading = re.search(r"^# (.+?)\s*$", body, re.M)
200
+ title = heading.group(1) if heading else meta.get("title")
201
+ tracker: list[dict] = []
202
+ table = tracker_table(text)
203
+ if table is not None:
204
+ columns, lines = table
205
+ status_at = columns.index("status")
206
+ for line in lines:
207
+ if not line.strip().startswith("|") or caddis_tidy._TABLE_RULE_RE.match(line.strip()):
208
+ continue
209
+ cells = caddis_tidy._row_cells(line)
210
+ if cells:
211
+ tracker.append({"phase": cells[0], "status": cells[status_at] if len(cells) > status_at else ""})
212
+ return {"path": path.relative_to(Path(root)).as_posix(), "title": title,
213
+ "status": meta.get("status"), "feature": meta.get("feature"),
214
+ "tracker": tracker, "open_rows": sum(1 for row in tracker if not _is_done(row["status"]))}
215
+
216
+
217
+ def plans_json(root: Path) -> dict:
218
+ """Every current plan as data: `{"schema": 1, "plans": [...]}`. Never prints, never exits.
219
+
220
+ A file that does not parse gives `{"path", "error"}`; the other plans still come back.
221
+ """
222
+ root = Path(root)
223
+ plans: list[dict] = []
224
+ for path in _plan_files(root):
225
+ try:
226
+ plans.append(_plan_entry(root, path))
227
+ except Exception as exc: # one bad file must not hide the others
228
+ rel = path.relative_to(root).as_posix()
229
+ plans.append({"path": rel, "error": (str(exc).splitlines() or [type(exc).__name__])[0]})
230
+ return {"schema": 1, "plans": plans}
231
+
232
+
175
233
  def main(argv: list[str] | None = None) -> int:
176
234
  # Plan paths may carry non-ASCII text; a Windows console defaults to a legacy code page.
177
235
  for stream in (sys.stdout, sys.stderr):
@@ -181,6 +239,10 @@ def main(argv: list[str] | None = None) -> int:
181
239
  parser.add_argument("--root", type=Path, default=Path.cwd(), help=argparse.SUPPRESS)
182
240
  commands = parser.add_subparsers(dest="command", required=True)
183
241
  commands.add_parser("active-plan")
242
+ plans_p = commands.add_parser("plans", help="list the plans as JSON")
243
+ plans_p.add_argument("--json", action="store_true", dest="as_json")
244
+ # `plans --root R` works too. SUPPRESS: when absent here, the global --root (or cwd) stays.
245
+ plans_p.add_argument("--root", type=Path, default=argparse.SUPPRESS, help="the project folder")
184
246
  # Retired: kept one release so an older /handoff Step 6 does not fail. Takes its old flag, ignores it.
185
247
  reconcile_p = commands.add_parser("reconcile", help=argparse.SUPPRESS)
186
248
  reconcile_p.add_argument("--session-state", type=Path)
@@ -190,6 +252,15 @@ def main(argv: list[str] | None = None) -> int:
190
252
  if args.command == "reconcile":
191
253
  print(RECONCILE_RETIRED)
192
254
  return 0
255
+ if args.command == "plans":
256
+ if not args.as_json:
257
+ parser.error("plans needs --json")
258
+ try:
259
+ print(json.dumps(plans_json(args.root), ensure_ascii=False))
260
+ except (OSError, ValueError) as exc: # a .caddis that escapes the project
261
+ print(f"caddis-todo: {exc}", file=sys.stderr)
262
+ return 1
263
+ return 0
193
264
  try:
194
265
  plan, candidates = active_plan(args.root)
195
266
  except (OSError, UnicodeError, ValueError) as exc: