@caddis/cli 0.4.75 → 0.4.76

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.161",
3
+ "version": "1.3.162",
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
@@ -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:
@@ -259,6 +259,45 @@ def _relays(directory: Path) -> list[tuple[str, dict, str]]:
259
259
  return out
260
260
 
261
261
 
262
+ JSON_SCHEMA = 1 # the `schema` key of every --json output (hub rule R13)
263
+
264
+
265
+ def _relay_dir(root: Path) -> Path:
266
+ artifact = _artifact(root)
267
+ directory = artifact / "relay"
268
+ if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
269
+ raise ValueError("relay directory escapes artifact root")
270
+ return directory
271
+
272
+
273
+ def relays_json(root: Path, now: datetime | None = None) -> dict:
274
+ """Return every valid relay as JSON data. A bad status line gives status INVALID."""
275
+ now = now or datetime.now(timezone.utc)
276
+ rows = []
277
+ for name, meta, content in _relays(_relay_dir(root)):
278
+ when = datetime.fromisoformat(meta["generated"]["at"].replace("Z", "+00:00"))
279
+ if when.tzinfo is None:
280
+ when = when.replace(tzinfo=timezone.utc)
281
+ when = when.astimezone(timezone.utc)
282
+ try:
283
+ status, status_arg = parse_status(content)
284
+ except ValueError as exc:
285
+ status, status_arg = "INVALID", str(exc)
286
+ step = _next_step(content)
287
+ rows.append({
288
+ "name": name,
289
+ "branch": meta.get("branch"),
290
+ "plan": meta.get("plan"),
291
+ "session_id": meta.get("session_id"),
292
+ "updated_at": when.strftime("%Y-%m-%dT%H:%M:%SZ"),
293
+ "age_min": max(0, int((now - when).total_seconds()) // 60),
294
+ "status": status,
295
+ "status_arg": status_arg,
296
+ "next_step": None if step == "next step unrecorded" else step,
297
+ })
298
+ return {"schema": JSON_SCHEMA, "relays": sorted(rows, key=lambda r: r["name"])}
299
+
300
+
262
301
  def _item_matches(item: str, next_text: str) -> bool:
263
302
  needle = item.strip()
264
303
  if not needle:
@@ -690,6 +729,7 @@ def main(argv: list[str] | None = None) -> int:
690
729
 
691
730
  list_p = sub.add_parser("list", help="list each relay's name, branch and next step")
692
731
  list_p.add_argument("--root", type=Path, default=Path("."))
732
+ list_p.add_argument("--json", action="store_true")
693
733
 
694
734
  mentions_p = sub.add_parser("mentions", help="list other relays whose Next step mentions an item")
695
735
  mentions_p.add_argument("--item", required=True)
@@ -735,7 +775,7 @@ def main(argv: list[str] | None = None) -> int:
735
775
  sys.stderr.write(f"invalid relay status: {exc}\n")
736
776
  return 1
737
777
  if args.json:
738
- print(json.dumps({"status": kind, "arg": arg}))
778
+ print(json.dumps({"schema": JSON_SCHEMA, "status": kind, "arg": arg}))
739
779
  else:
740
780
  if arg:
741
781
  print(f"STATUS: {kind} {arg}")
@@ -788,10 +828,18 @@ def main(argv: list[str] | None = None) -> int:
788
828
  print(f" conflict: {conflict}")
789
829
  return 0 if report.status in ("clean", "migrated") else 1
790
830
  elif args.command == "list":
791
- artifact = _artifact(args.root)
792
- directory = artifact / "relay"
793
- if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
794
- raise ValueError("relay directory escapes artifact root")
831
+ try:
832
+ data = relays_json(args.root) if args.json else None
833
+ directory = _relay_dir(args.root)
834
+ except ValueError as exc: # the relay folder escapes .caddis: one error, never a traceback
835
+ if args.json:
836
+ print(json.dumps({"schema": JSON_SCHEMA, "error": str(exc)}))
837
+ else:
838
+ sys.stderr.write(f"cannot list relays: {exc}\n")
839
+ return 1
840
+ if args.json:
841
+ print(json.dumps(data, indent=2))
842
+ return 0
795
843
  for name, meta, content in _relays(directory):
796
844
  branch_label = _single_line(str(meta.get("branch", "?")), 50)
797
845
  print(f"{name}\t{branch_label}\t{_next_step(content)}")