@caddis/cli 0.4.74 → 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_coordinate.py +106 -4
- 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/antigravity-plugin/scripts/templates/coordinator.md +5 -3
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +37 -7
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -962,7 +962,10 @@ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name:
|
|
|
962
962
|
|
|
963
963
|
CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
|
|
964
964
|
"main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
|
|
965
|
-
OPTIONAL_CONFIG_KEYS = ("relay",) #
|
|
965
|
+
OPTIONAL_CONFIG_KEYS = ("relay", "start_mode") # relay: `.caddis/relay/<relay>.md`; start_mode: see START_MODES
|
|
966
|
+
# The merge mode a start begins in. "target" in the reviewed, merged config is the owner's standing consent
|
|
967
|
+
# for the coordinator to run `gh pr merge` (command section 2). Missing means "interim".
|
|
968
|
+
START_MODES = ("interim", "target")
|
|
966
969
|
CONFIG_SCHEMA = 1
|
|
967
970
|
_PLACEHOLDER = "<fill:"
|
|
968
971
|
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
|
|
@@ -1018,8 +1021,9 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1018
1021
|
"""Read `<repo_root>/.caddis/coordinator.md` and return its json block.
|
|
1019
1022
|
|
|
1020
1023
|
Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
|
|
1021
|
-
`schema` is not 1, a value is still a `<fill: ...>` placeholder,
|
|
1022
|
-
workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name)
|
|
1024
|
+
`schema` is not 1, a value is still a `<fill: ...>` placeholder, the optional `relay` is not a plain
|
|
1025
|
+
workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name), or the
|
|
1026
|
+
optional `start_mode` is not one of START_MODES."""
|
|
1023
1027
|
path = config_path(repo_root)
|
|
1024
1028
|
try:
|
|
1025
1029
|
text = path.read_text(encoding="utf-8")
|
|
@@ -1040,9 +1044,92 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1040
1044
|
if "relay" in config and not _plain_workstream_name(config["relay"]):
|
|
1041
1045
|
raise ValueError(f"the config relay must be a plain workstream name such as coordinator, "
|
|
1042
1046
|
f"not {config['relay']!r}")
|
|
1047
|
+
if "start_mode" in config and config["start_mode"] not in START_MODES:
|
|
1048
|
+
raise ValueError(f"the config start_mode must be one of {', '.join(START_MODES)}, "
|
|
1049
|
+
f"not {config['start_mode']!r}")
|
|
1043
1050
|
return config
|
|
1044
1051
|
|
|
1045
1052
|
|
|
1053
|
+
_CONFIG_REL = ".caddis/coordinator.md"
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _start_mode_at(repo_root: Path, rev: str) -> str | None:
|
|
1057
|
+
"""`start_mode` in the config at `rev`: a mode, None (no file or no key) or "invalid"."""
|
|
1058
|
+
shown = _run(["git", "-C", str(repo_root), "show", f"{rev}:{_CONFIG_REL}"])
|
|
1059
|
+
if shown.returncode != 0:
|
|
1060
|
+
return None
|
|
1061
|
+
try:
|
|
1062
|
+
block = _config_block(shown.stdout)
|
|
1063
|
+
except ValueError:
|
|
1064
|
+
return "invalid"
|
|
1065
|
+
if not isinstance(block, dict):
|
|
1066
|
+
return "invalid"
|
|
1067
|
+
if "start_mode" not in block:
|
|
1068
|
+
return None
|
|
1069
|
+
return block["start_mode"] if block["start_mode"] in START_MODES else "invalid"
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _origin_default_branch(repo_root: Path) -> str | None:
|
|
1073
|
+
"""The default branch from `refs/remotes/origin/HEAD`, never from the work tree (review, 24 Oct 2026)."""
|
|
1074
|
+
out = _run(["git", "-C", str(repo_root), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
|
|
1075
|
+
ref = out.stdout.strip() if out.returncode == 0 else ""
|
|
1076
|
+
return ref[len("origin/"):] if ref.startswith("origin/") and len(ref) > len("origin/") else None
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _set_target_commit(repo_root: Path, ref: str) -> tuple[str | None, str | None]:
|
|
1080
|
+
"""The commit on `ref` where `start_mode` last became "target" (newest first: the oldest commit of the newest
|
|
1081
|
+
unbroken run of target versions). The ledger cites it as the owner's consent."""
|
|
1082
|
+
out = _run(["git", "-C", str(repo_root), "log", "--format=%H%x09%s", ref, "--", _CONFIG_REL])
|
|
1083
|
+
if out.returncode != 0:
|
|
1084
|
+
return None, None
|
|
1085
|
+
found: tuple[str | None, str | None] = (None, None)
|
|
1086
|
+
for line in out.stdout.splitlines():
|
|
1087
|
+
sha, _, subject = line.partition("\t")
|
|
1088
|
+
if _start_mode_at(repo_root, sha) != "target":
|
|
1089
|
+
break
|
|
1090
|
+
found = (sha, subject)
|
|
1091
|
+
return found
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def config_mode(repo_root: Path) -> dict:
|
|
1095
|
+
"""The merge mode a start begins in. Target only when origin's default branch AND the work tree both say
|
|
1096
|
+
`"start_mode": "target"`: the work tree of a shared checkout can hold an uncommitted edit that no owner
|
|
1097
|
+
approved (review, 24 Oct 2026). The branch comes from `origin/HEAD`, not from the work-tree config; a work
|
|
1098
|
+
tree whose `default_branch` differs is a mismatch. Returns mode, work_tree, origin, mismatch, set_by (the
|
|
1099
|
+
commit that set target) and set_by_subject. Fetch first; a stale origin ref gives a stale answer."""
|
|
1100
|
+
work = load_config(repo_root)
|
|
1101
|
+
branch = _origin_default_branch(repo_root)
|
|
1102
|
+
origin = _start_mode_at(repo_root, f"origin/{branch}") if branch else "invalid"
|
|
1103
|
+
work_mode = work.get("start_mode")
|
|
1104
|
+
set_by = set_by_subject = None
|
|
1105
|
+
if origin == "target":
|
|
1106
|
+
set_by, set_by_subject = _set_target_commit(repo_root, f"origin/{branch}")
|
|
1107
|
+
|
|
1108
|
+
def effective(value: str | None) -> str: # missing means interim
|
|
1109
|
+
return value or "interim"
|
|
1110
|
+
mismatch = (origin == "invalid" or branch != work["default_branch"]
|
|
1111
|
+
or effective(work_mode) != effective(origin))
|
|
1112
|
+
mode = "target" if not mismatch and effective(origin) == "target" else "interim"
|
|
1113
|
+
return {"mode": mode, "work_tree": work_mode, "origin": origin, "mismatch": mismatch,
|
|
1114
|
+
"set_by": set_by, "set_by_subject": set_by_subject}
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def config_changed(diff: str) -> bool:
|
|
1118
|
+
"""True when a unified diff touches .caddis/coordinator.md in any way: add, delete, rename to or from it,
|
|
1119
|
+
edit, binary or mode-only change. That file holds the coordinator's own rules (start_mode, required checks,
|
|
1120
|
+
owner_reserved), so such a PR is owner-only (R4) in every mode. File-level on purpose: a text match on keys
|
|
1121
|
+
can be dodged with JSON escapes (review, 24 Oct 2026)."""
|
|
1122
|
+
marks = (f"+++ b/{_CONFIG_REL}", f"--- a/{_CONFIG_REL}", f"rename from {_CONFIG_REL}", f"rename to {_CONFIG_REL}",
|
|
1123
|
+
f"copy from {_CONFIG_REL}", f"copy to {_CONFIG_REL}")
|
|
1124
|
+
for line in diff.splitlines():
|
|
1125
|
+
if line in marks:
|
|
1126
|
+
return True
|
|
1127
|
+
if line.startswith("diff --git ") and (line.startswith(f"diff --git a/{_CONFIG_REL} ")
|
|
1128
|
+
or line.endswith(f" b/{_CONFIG_REL}")):
|
|
1129
|
+
return True
|
|
1130
|
+
return False
|
|
1131
|
+
|
|
1132
|
+
|
|
1046
1133
|
def _plain_workstream_name(value: object) -> bool:
|
|
1047
1134
|
"""The relay name rule of `caddis_workstreams` (one rule, so a relay that loads here can also be read there)."""
|
|
1048
1135
|
try:
|
|
@@ -1497,9 +1584,12 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1497
1584
|
p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
|
|
1498
1585
|
|
|
1499
1586
|
config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
|
|
1500
|
-
for name in ("show", "init"):
|
|
1587
|
+
for name in ("show", "init", "mode"):
|
|
1501
1588
|
p = command(name, config, repo=False)
|
|
1502
1589
|
p.add_argument("--path", default=".", help="the repo root (default: current folder)")
|
|
1590
|
+
p = command("pr-check", config, repo=False)
|
|
1591
|
+
p.add_argument("--pr", type=int, required=True, help="the PR whose diff to check (`gh pr diff`)")
|
|
1592
|
+
p.add_argument("--gh-repo", help="owner/name for `gh pr diff -R` (default: the current folder's repo)")
|
|
1503
1593
|
|
|
1504
1594
|
p = command("session", repo=False)
|
|
1505
1595
|
which = p.add_mutually_exclusive_group(required=True)
|
|
@@ -1585,6 +1675,18 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1585
1675
|
if args.config_command == "init":
|
|
1586
1676
|
path = init_config(Path(args.path))
|
|
1587
1677
|
_emit(args, {"path": str(path)}, str(path))
|
|
1678
|
+
elif args.config_command == "mode":
|
|
1679
|
+
got = config_mode(Path(args.path))
|
|
1680
|
+
_emit(args, got, got["mode"] + (" (work tree and origin differ)" if got["mismatch"] else ""))
|
|
1681
|
+
elif args.config_command == "pr-check":
|
|
1682
|
+
cmd = ["gh", "pr", "diff", str(args.pr)] + (["-R", args.gh_repo] if args.gh_repo else [])
|
|
1683
|
+
out = _run(cmd)
|
|
1684
|
+
if out.returncode != 0:
|
|
1685
|
+
raise ValueError(f"gh pr diff {args.pr} failed: {out.stderr.strip()[:200]}")
|
|
1686
|
+
changed = config_changed(out.stdout)
|
|
1687
|
+
_emit(args, {"pr": args.pr, "changes_config": changed},
|
|
1688
|
+
"owner-only: the PR changes .caddis/coordinator.md" if changed else "no coordinator config change")
|
|
1689
|
+
return EXIT_LOCK_REFUSED if changed else EXIT_OK
|
|
1588
1690
|
else:
|
|
1589
1691
|
loaded = load_config(Path(args.path))
|
|
1590
1692
|
_emit(args, loaded, json.dumps(loaded, indent=2))
|
|
@@ -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:
|