@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_hub_collect.py +224 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +37 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +16 -2
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +73 -2
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +53 -5
- package/bundles/antigravity-plugin/scripts/schemas/hub-collect.schema.json +733 -0
- package/bundles/antigravity-plugin/scripts/schemas/hub-guide.schema.json +214 -0
- package/bundles/antigravity-plugin/scripts/schemas/hub-plans.schema.json +91 -0
- package/bundles/antigravity-plugin/scripts/schemas/hub-relays.schema.json +79 -0
- package/bundles/antigravity-plugin/scripts/schemas/hub-roadmaps.schema.json +185 -0
- package/bundles/antigravity-plugin/scripts/schemas/hub-spend.schema.json +39 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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)}")
|