@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.
- package/bundles/antigravity-plugin/guard_agy.py +131 -1
- 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_temp_prune.py +1 -0
- 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/dist/cli.js +2717 -130
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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):
|
|
@@ -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
|
|
@@ -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:
|