@caddis/cli 0.4.69 → 0.4.71
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_budget.py +0 -38
- package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +1083 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +5 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +10 -451
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +6 -7
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +35 -352
- package/bundles/antigravity-plugin/scripts/caddis_upstream_check.py +107 -0
- package/bundles/antigravity-plugin/scripts/schemas/coordinator.schema.json +95 -0
- package/bundles/antigravity-plugin/scripts/schemas/request.schema.json +39 -0
- package/bundles/antigravity-plugin/skills/_registry.md +1 -1
- package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +64 -1
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +25 -39
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +14 -0
- package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +9 -0
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +15 -375
- package/bundles/antigravity-plugin/warm_start_agy.py +3 -40
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +15 -375
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +1 -1
- package/bundles/codex/.codex/skills/workflow/brainstorming/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The active plan and the plan Tracker reader, for commands, tidy and the gates.
|
|
2
|
+
|
|
3
|
+
3-store model (owner decision, 21 Oct 2026): the session task list (`.caddis/relay/<name>.tasks.md`), the
|
|
4
|
+
parking lot and the roadmap with the plan Tracker. The `.caddis/todo-list/` store and the open-work view
|
|
5
|
+
are retired, with the `add`, `done`, `list` and `open-work` sub-commands. `reconcile` stays one more
|
|
6
|
+
release as a no-op, because an older /handoff still calls it from its Step 6.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
5
11
|
import argparse
|
|
6
|
-
import json
|
|
7
12
|
import os
|
|
8
13
|
import re
|
|
9
14
|
import subprocess
|
|
10
15
|
import sys
|
|
11
|
-
import uuid
|
|
12
16
|
from pathlib import Path
|
|
13
17
|
|
|
14
18
|
try:
|
|
15
19
|
import caddis_exit
|
|
16
|
-
from
|
|
17
|
-
from caddis_frontmatter import parse, split_document
|
|
20
|
+
from caddis_frontmatter import parse
|
|
18
21
|
from caddis_workstreams import _artifact, _name
|
|
19
22
|
except ModuleNotFoundError as exc:
|
|
20
23
|
if exc.name not in {"caddis_exit", "caddis_file_lock", "caddis_frontmatter", "caddis_workstreams"}:
|
|
21
24
|
raise
|
|
25
|
+
# caddis_tidy imports caddis_file_lock after this module: these two folders stay on the path.
|
|
22
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
27
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
24
28
|
import caddis_exit
|
|
25
|
-
from
|
|
26
|
-
from caddis_frontmatter import parse, split_document
|
|
29
|
+
from caddis_frontmatter import parse
|
|
27
30
|
from caddis_workstreams import _artifact, _name
|
|
28
31
|
|
|
29
32
|
|
|
@@ -31,81 +34,9 @@ except ModuleNotFoundError as exc:
|
|
|
31
34
|
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
32
35
|
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
33
36
|
|
|
34
|
-
_ITEM = re.compile(r'^- \[([ x])\] (.+?) <!-- caddis: (\{.*\}) -->$')
|
|
35
37
|
_PHASE = re.compile(r'^\|\s*(\d+)\s*\|', re.M)
|
|
36
|
-
_WORD = re.compile(r'[a-z0-9]{3,}')
|
|
37
38
|
_PLAN_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,150}")
|
|
38
|
-
|
|
39
|
-
_STATUS_MARKER = re.compile("[\\s\u2300-\u27bf\U0001f300-\U0001faff\ufe0f]+$")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _path(root: Path, workstream: str | None) -> tuple[Path, Path]:
|
|
43
|
-
artifact = _artifact(root)
|
|
44
|
-
name = _name(workstream) if workstream is not None else "inbox"
|
|
45
|
-
folder = artifact / "todo-list"
|
|
46
|
-
# Only a folder that EXISTS can point elsewhere. Resolving one that does not exist yet raced a concurrent
|
|
47
|
-
# writer creating it: on Windows the two paths could resolve differently (a short 8.3 name on one side), and a
|
|
48
|
-
# safe add failed with this error (main CI, 20 Oct 2026, test_concurrent_inbox_add_loses_no_item).
|
|
49
|
-
if folder.is_symlink() or (folder.exists() and not folder.resolve().is_relative_to(artifact.resolve())):
|
|
50
|
-
raise ValueError("todo-list path escapes artifact root")
|
|
51
|
-
path = folder / f"{name}.md"
|
|
52
|
-
# Reads are unlocked, so refuse here as well as in locked_path. On Windows a writer replacing the file
|
|
53
|
-
# makes this probe (and the read below) fail with PermissionError for an instant, so both retry.
|
|
54
|
-
if retry_denied(path.is_symlink):
|
|
55
|
-
raise ValueError("to-do file may not be a symlink")
|
|
56
|
-
return artifact, path
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _read(path: Path, workstream: str | None) -> list[dict]:
|
|
60
|
-
if not retry_denied(path.exists):
|
|
61
|
-
return []
|
|
62
|
-
text = retry_denied(lambda: path.read_text(encoding="utf-8"))
|
|
63
|
-
header, body = split_document(text)
|
|
64
|
-
name = workstream or "inbox"
|
|
65
|
-
if header.get("type") != "todo" or header.get("workstream") != name or header.get("status") not in {"current", "done"}:
|
|
66
|
-
raise ValueError(f"invalid to-do header: {path}")
|
|
67
|
-
items = []
|
|
68
|
-
for line in body.splitlines():
|
|
69
|
-
if not line.strip() or line == "# To-dos":
|
|
70
|
-
continue
|
|
71
|
-
match = _ITEM.fullmatch(line)
|
|
72
|
-
if not match:
|
|
73
|
-
raise ValueError(f"invalid to-do row: {path}")
|
|
74
|
-
data = json.loads(match.group(3))
|
|
75
|
-
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
|
|
76
|
-
raise ValueError(f"invalid to-do item: {path}")
|
|
77
|
-
uuid.UUID(data["id"])
|
|
78
|
-
if "resolves" in data:
|
|
79
|
-
_resolves_path(path.parents[2], data["resolves"])
|
|
80
|
-
items.append({"id": data["id"], "title": match.group(2), "done": match.group(1) == "x",
|
|
81
|
-
**{key: data[key] for key in ("resolves", "partial") if key in data}})
|
|
82
|
-
if len({item["id"] for item in items}) != len(items):
|
|
83
|
-
raise ValueError(f"duplicate to-do id: {path}")
|
|
84
|
-
expected = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
|
|
85
|
-
if header["status"] != expected:
|
|
86
|
-
raise ValueError(f"to-do status mismatch: {path}")
|
|
87
|
-
return items
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _write(path: Path, root: Path, workstream: str | None, items: list[dict]) -> None:
|
|
91
|
-
status = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
|
|
92
|
-
lines = ["---", "type: todo", f"status: {status}", f"workstream: {workstream or 'inbox'}",
|
|
93
|
-
"---", "", "# To-dos", ""]
|
|
94
|
-
for item in items:
|
|
95
|
-
metadata = {"id": item["id"]}
|
|
96
|
-
for key in ("resolves", "partial"):
|
|
97
|
-
if key in item:
|
|
98
|
-
metadata[key] = item[key]
|
|
99
|
-
box = "x" if item["done"] else " "
|
|
100
|
-
lines.append(f'- [{box}] {item["title"]} <!-- caddis: {json.dumps(metadata, sort_keys=True)} -->')
|
|
101
|
-
atomic_write(path, "\n".join(lines) + "\n", root=root)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _title(value: str) -> str:
|
|
105
|
-
title = " ".join(value.split())
|
|
106
|
-
if not title or len(title) > 300 or "<!--" in title or "-->" in title:
|
|
107
|
-
raise ValueError("to-do text must be one plain line (1-300 characters)")
|
|
108
|
-
return title
|
|
39
|
+
RECONCILE_RETIRED = "caddis_todo: reconcile is retired (3-store model); nothing to do"
|
|
109
40
|
|
|
110
41
|
|
|
111
42
|
def _resolves_path(root: Path, value: str) -> Path:
|
|
@@ -123,58 +54,6 @@ def _resolves_path(root: Path, value: str) -> Path:
|
|
|
123
54
|
return target
|
|
124
55
|
|
|
125
56
|
|
|
126
|
-
def _suggest(root: Path, title: str) -> list[str]:
|
|
127
|
-
parking = _artifact(root) / "parking-lot"
|
|
128
|
-
if not parking.is_dir() or parking.is_symlink():
|
|
129
|
-
return []
|
|
130
|
-
words = set(_WORD.findall(title.lower()))
|
|
131
|
-
found = []
|
|
132
|
-
for path in sorted(parking.glob("*.md")):
|
|
133
|
-
if path.is_symlink() or path.name == "README.md":
|
|
134
|
-
continue
|
|
135
|
-
if words & set(_WORD.findall(path.stem.lower())) or path.name.lower() in title.lower():
|
|
136
|
-
found.append(f"parking-lot/{path.name}")
|
|
137
|
-
return found
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def add(root: Path, title: str, workstream: str | None = None, *, resolves: str | None = None,
|
|
141
|
-
partial: bool = False) -> dict:
|
|
142
|
-
title = _title(title)
|
|
143
|
-
artifact, path = _path(root, workstream)
|
|
144
|
-
if partial and not resolves:
|
|
145
|
-
raise ValueError("--partial requires --resolves")
|
|
146
|
-
linked = f"parking-lot/{_resolves_path(root, resolves).name}" if resolves else None
|
|
147
|
-
with locked_path(path, root=artifact):
|
|
148
|
-
items = _read(path, workstream)
|
|
149
|
-
item = {"id": str(uuid.uuid4()), "title": title, "done": False}
|
|
150
|
-
if linked:
|
|
151
|
-
item["resolves"] = linked
|
|
152
|
-
if partial:
|
|
153
|
-
item["partial"] = True
|
|
154
|
-
items.append(item)
|
|
155
|
-
_write(path, artifact, workstream, items)
|
|
156
|
-
return item
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def done(root: Path, item_id: str, workstream: str) -> dict:
|
|
160
|
-
artifact, path = _path(root, workstream)
|
|
161
|
-
with locked_path(path, root=artifact):
|
|
162
|
-
items = _read(path, workstream)
|
|
163
|
-
matches = [item for item in items if item["id"] == item_id]
|
|
164
|
-
if not matches:
|
|
165
|
-
raise ValueError("unknown to-do id")
|
|
166
|
-
item = matches[0]
|
|
167
|
-
if not item["done"]:
|
|
168
|
-
item["done"] = True
|
|
169
|
-
_write(path, artifact, workstream, items)
|
|
170
|
-
return item
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def list_items(root: Path, workstream: str | None = None) -> list[dict]:
|
|
174
|
-
_, path = _path(root, workstream)
|
|
175
|
-
return [item for item in _read(path, workstream) if not item["done"]]
|
|
176
|
-
|
|
177
|
-
|
|
178
57
|
def _is_done(status: str) -> bool:
|
|
179
58
|
"""A Tracker status cell that means done: "done", "done (note)" or a leading check mark.
|
|
180
59
|
A cell that says work is still pending is not done (owner decision 2026-09-27)."""
|
|
@@ -276,7 +155,7 @@ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
|
|
|
276
155
|
|
|
277
156
|
None when there is no Tracker heading line, no `| Phase |` header or no Status column. The
|
|
278
157
|
lines are every line of the section except the header; callers pick the rows they trust.
|
|
279
|
-
One reader, so
|
|
158
|
+
One reader, so caddis_tidy and any later caller never disagree about the same table.
|
|
280
159
|
"""
|
|
281
160
|
# A real heading line only: plan prose may mention "## Tracker" inline before the section.
|
|
282
161
|
heading = re.search(r"^## Tracker\b.*$", text, re.M)
|
|
@@ -293,235 +172,39 @@ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
|
|
|
293
172
|
return columns, table[:at] + table[at + 1:]
|
|
294
173
|
|
|
295
174
|
|
|
296
|
-
def _plan_rows(plan: Path | None) -> list[dict]:
|
|
297
|
-
if plan is None:
|
|
298
|
-
return []
|
|
299
|
-
text = plan.read_text(encoding="utf-8")
|
|
300
|
-
table = tracker_table(text)
|
|
301
|
-
if table is None:
|
|
302
|
-
return []
|
|
303
|
-
columns, lines = table
|
|
304
|
-
rows = []
|
|
305
|
-
for line in lines:
|
|
306
|
-
match = _PHASE.match(line)
|
|
307
|
-
if not match:
|
|
308
|
-
continue
|
|
309
|
-
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
|
310
|
-
if len(cells) != len(columns) or _is_done(cells[columns.index("status")]):
|
|
311
|
-
continue
|
|
312
|
-
number = match.group(1)
|
|
313
|
-
heading = re.search(rf"^### Phase {number}\s+[-—]\s+(.+)$", text, re.M)
|
|
314
|
-
# Drop the heading's trailing status marker (🔲, ⏳, ✅ ...): status lives in the Tracker.
|
|
315
|
-
title = _STATUS_MARKER.sub("", heading.group(1)) if heading else f"Phase {number}"
|
|
316
|
-
rows.append({"id": f"phase:{number}", "kind": "phase", "title": title})
|
|
317
|
-
return rows
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def _split_cells(line: str) -> list[str]:
|
|
321
|
-
r"""Stripped cells of one table line, split on unescaped pipes only (`a \| b` is one cell)."""
|
|
322
|
-
parts = re.split(r"(?<!\\)\|", line.strip())
|
|
323
|
-
if parts and parts[0] == "":
|
|
324
|
-
parts.pop(0)
|
|
325
|
-
if parts and parts[-1] == "":
|
|
326
|
-
parts.pop(-1)
|
|
327
|
-
return [part.strip() for part in parts]
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def _open_work_rows(root: Path) -> list[dict]:
|
|
331
|
-
path = _artifact(root) / "open-work.md"
|
|
332
|
-
if path.is_symlink() or not path.is_file():
|
|
333
|
-
return []
|
|
334
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
335
|
-
header, body = split_document(text)
|
|
336
|
-
if header.get("type") != "open-work":
|
|
337
|
-
return []
|
|
338
|
-
rows: list[dict] = []
|
|
339
|
-
columns: list[str] | None = None
|
|
340
|
-
id_idx = status_idx = title_idx = 0
|
|
341
|
-
for raw_line in body.splitlines():
|
|
342
|
-
line = raw_line.strip()
|
|
343
|
-
if not line.startswith("|"):
|
|
344
|
-
columns = None
|
|
345
|
-
continue
|
|
346
|
-
cells = _split_cells(line) # unescaped pipes only, as caddis_rows.split_cells
|
|
347
|
-
if not cells or all(c and set(c) <= set("-: ") and "-" in c for c in cells):
|
|
348
|
-
continue
|
|
349
|
-
if columns is None:
|
|
350
|
-
lowered = [c.lower() for c in cells]
|
|
351
|
-
if "id" in lowered and "status" in lowered:
|
|
352
|
-
columns = lowered
|
|
353
|
-
id_idx = columns.index("id")
|
|
354
|
-
status_idx = columns.index("status")
|
|
355
|
-
title_idx = next(
|
|
356
|
-
(i for i, col in enumerate(columns) if col in {"task", "title", "item"}),
|
|
357
|
-
id_idx + 1,
|
|
358
|
-
)
|
|
359
|
-
continue
|
|
360
|
-
if len(cells) != len(columns):
|
|
361
|
-
continue
|
|
362
|
-
status = cells[status_idx].strip().lower()
|
|
363
|
-
first = status.split(" ", 1)[0] if status else ""
|
|
364
|
-
if _is_done(status) or first in {"not-needed", "✓", "✔"}:
|
|
365
|
-
continue
|
|
366
|
-
row_id = cells[id_idx].replace("**", "").replace("__", "").replace("`", "").strip()
|
|
367
|
-
if not row_id:
|
|
368
|
-
continue
|
|
369
|
-
title = cells[title_idx] if title_idx < len(cells) else ""
|
|
370
|
-
rows.append({"id": row_id, "kind": "open-work", "title": title})
|
|
371
|
-
return rows
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def open_work(root: Path, workstream: str | None = None) -> list[dict]:
|
|
375
|
-
rows = _plan_rows(_active_plan(root, workstream))
|
|
376
|
-
for name in (None, workstream) if workstream and workstream != "inbox" else (None,):
|
|
377
|
-
rows.extend({"id": item["id"], "kind": "todo", "title": item["title"],
|
|
378
|
-
**{key: item[key] for key in ("resolves", "partial") if key in item}}
|
|
379
|
-
for item in list_items(root, name))
|
|
380
|
-
rows.extend(_open_work_rows(root))
|
|
381
|
-
return rows
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def _extract_completed_ids(text: str) -> set[str]:
|
|
385
|
-
"""Open-work ids of the done rows in a session-state file's "## Tasks" table.
|
|
386
|
-
|
|
387
|
-
Only the marker session_state.py writes after a task's subject counts, as in
|
|
388
|
-
``| 3 | done | Fix x <!-- caddis: {"id": "..."} --> |``. A UUID that merely appears in a
|
|
389
|
-
task's text is never trusted: "depends on <uuid>" must not close that other item.
|
|
390
|
-
"""
|
|
391
|
-
ids: set[str] = set()
|
|
392
|
-
in_tasks = False
|
|
393
|
-
for line in text.splitlines():
|
|
394
|
-
row = line.strip()
|
|
395
|
-
if row.startswith("## "):
|
|
396
|
-
in_tasks = row == "## Tasks"
|
|
397
|
-
continue
|
|
398
|
-
if not in_tasks or not row.startswith("|") or row.startswith("|---"):
|
|
399
|
-
continue
|
|
400
|
-
cells = [cell.strip() for cell in row.strip("|").split("|")]
|
|
401
|
-
# Status is the second cell; a "|" inside the subject only shifts later cells.
|
|
402
|
-
if len(cells) < 3 or cells[1].lower() not in {"done", "completed"}:
|
|
403
|
-
continue
|
|
404
|
-
marker = re.search(r"<!--\s*caddis:\s*(\{.*?\})\s*-->\s*\|?$", row)
|
|
405
|
-
if not marker:
|
|
406
|
-
continue
|
|
407
|
-
try:
|
|
408
|
-
value = json.loads(marker.group(1)).get("id")
|
|
409
|
-
except (ValueError, AttributeError):
|
|
410
|
-
continue
|
|
411
|
-
if isinstance(value, str) and value:
|
|
412
|
-
ids.add(value.lower())
|
|
413
|
-
return ids
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def reconcile(root: Path, session_state: Path) -> list[str]:
|
|
417
|
-
"""Reconcile completed task rows from session-state into todo-lists.
|
|
418
|
-
|
|
419
|
-
Marks ad-hoc todo items done by metadata ID.
|
|
420
|
-
Never edits the Tracker; phase rows are ignored.
|
|
421
|
-
"""
|
|
422
|
-
if not session_state.is_file():
|
|
423
|
-
return []
|
|
424
|
-
|
|
425
|
-
text = session_state.read_text(encoding="utf-8", errors="replace")
|
|
426
|
-
completed_ids = _extract_completed_ids(text)
|
|
427
|
-
if not completed_ids:
|
|
428
|
-
return []
|
|
429
|
-
|
|
430
|
-
artifact = _artifact(root)
|
|
431
|
-
todo_dir = artifact / "todo-list"
|
|
432
|
-
if not todo_dir.is_dir():
|
|
433
|
-
return []
|
|
434
|
-
|
|
435
|
-
reconciled: list[str] = []
|
|
436
|
-
# Ignore phase rows — Tracker is never edited!
|
|
437
|
-
adhoc_ids = {cid for cid in completed_ids if not cid.startswith("phase:")}
|
|
438
|
-
if not adhoc_ids:
|
|
439
|
-
return []
|
|
440
|
-
|
|
441
|
-
wanted = {a.lower() for a in adhoc_ids}
|
|
442
|
-
for todo_file in todo_dir.glob("*.md"):
|
|
443
|
-
if todo_file.is_symlink() or not todo_file.is_file():
|
|
444
|
-
continue
|
|
445
|
-
workstream_name = todo_file.stem
|
|
446
|
-
try:
|
|
447
|
-
items = _read(todo_file, workstream_name)
|
|
448
|
-
except (ValueError, OSError):
|
|
449
|
-
continue # a hand-edited file caddis cannot read; the others still reconcile
|
|
450
|
-
for item in items:
|
|
451
|
-
item_id = item.get("id")
|
|
452
|
-
if item_id and item_id.lower() in wanted and not item.get("done"):
|
|
453
|
-
done(root, item_id, workstream_name)
|
|
454
|
-
reconciled.append(item_id)
|
|
455
|
-
|
|
456
|
-
return reconciled
|
|
457
|
-
|
|
458
|
-
|
|
459
175
|
def main(argv: list[str] | None = None) -> int:
|
|
460
|
-
#
|
|
176
|
+
# Plan paths may carry non-ASCII text; a Windows console defaults to a legacy code page.
|
|
461
177
|
for stream in (sys.stdout, sys.stderr):
|
|
462
178
|
if hasattr(stream, "reconfigure"):
|
|
463
179
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
464
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
180
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
465
181
|
parser.add_argument("--root", type=Path, default=Path.cwd(), help=argparse.SUPPRESS)
|
|
466
182
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
467
|
-
listing = commands.add_parser("list")
|
|
468
|
-
listing.add_argument("workstream", nargs="?")
|
|
469
|
-
adding = commands.add_parser("add")
|
|
470
|
-
adding.add_argument("text")
|
|
471
|
-
adding.add_argument("--workstream")
|
|
472
|
-
adding.add_argument("--resolves")
|
|
473
|
-
adding.add_argument("--partial", action="store_true")
|
|
474
|
-
finishing = commands.add_parser("done")
|
|
475
|
-
finishing.add_argument("id")
|
|
476
|
-
finishing.add_argument("--workstream", required=True)
|
|
477
|
-
view = commands.add_parser("open-work")
|
|
478
|
-
view.add_argument("--workstream")
|
|
479
|
-
view.add_argument("--json", action="store_true")
|
|
480
|
-
reconcile_p = commands.add_parser("reconcile")
|
|
481
|
-
reconcile_p.add_argument("--session-state", type=Path, required=True)
|
|
482
183
|
commands.add_parser("active-plan")
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
elif args.command == "done":
|
|
492
|
-
item = done(args.root, args.id, args.workstream)
|
|
493
|
-
if "resolves" in item:
|
|
494
|
-
prefix = "Partly-Resolves-Parked" if item.get("partial") else "Resolves-Parked"
|
|
495
|
-
print(f'{prefix}: {item["resolves"]}')
|
|
496
|
-
elif args.command == "list":
|
|
497
|
-
for item in list_items(args.root, args.workstream):
|
|
498
|
-
print(f'{item["id"]} {item["title"]}')
|
|
499
|
-
elif args.command == "active-plan":
|
|
500
|
-
plan, candidates = active_plan(args.root)
|
|
501
|
-
if plan is None:
|
|
502
|
-
if candidates:
|
|
503
|
-
print("caddis-todo: several current plans; name one:", file=sys.stderr)
|
|
504
|
-
for candidate in candidates:
|
|
505
|
-
print(f" {candidate}", file=sys.stderr)
|
|
506
|
-
else:
|
|
507
|
-
print("caddis-todo: no current plan in .caddis/plans", file=sys.stderr)
|
|
508
|
-
return caddis_exit.NOT_RUN
|
|
509
|
-
print(plan)
|
|
510
|
-
elif args.command == "reconcile":
|
|
511
|
-
reconciled = reconcile(args.root, args.session_state)
|
|
512
|
-
for rid in reconciled:
|
|
513
|
-
print(f"Reconciled: {rid}")
|
|
514
|
-
else:
|
|
515
|
-
rows = open_work(args.root, args.workstream)
|
|
516
|
-
if args.json:
|
|
517
|
-
print(json.dumps(rows, ensure_ascii=False))
|
|
518
|
-
else:
|
|
519
|
-
for row in rows:
|
|
520
|
-
print(f'{row["id"]} [{row["kind"]}] {row["title"]}')
|
|
184
|
+
# Retired: kept one release so an older /handoff Step 6 does not fail. Takes its old flag, ignores it.
|
|
185
|
+
reconcile_p = commands.add_parser("reconcile", help=argparse.SUPPRESS)
|
|
186
|
+
reconcile_p.add_argument("--session-state", type=Path)
|
|
187
|
+
args, extra = parser.parse_known_args(argv)
|
|
188
|
+
if extra and args.command != "reconcile":
|
|
189
|
+
parser.error(f"unrecognized arguments: {' '.join(extra)}") # only the retired no-op tolerates old flags
|
|
190
|
+
if args.command == "reconcile":
|
|
191
|
+
print(RECONCILE_RETIRED)
|
|
521
192
|
return 0
|
|
522
|
-
|
|
193
|
+
try:
|
|
194
|
+
plan, candidates = active_plan(args.root)
|
|
195
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
523
196
|
print(f"caddis-todo: {exc}", file=sys.stderr)
|
|
524
197
|
return 1
|
|
198
|
+
if plan is None:
|
|
199
|
+
if candidates:
|
|
200
|
+
print("caddis-todo: several current plans; name one:", file=sys.stderr)
|
|
201
|
+
for candidate in candidates:
|
|
202
|
+
print(f" {candidate}", file=sys.stderr)
|
|
203
|
+
else:
|
|
204
|
+
print("caddis-todo: no current plan in .caddis/plans", file=sys.stderr)
|
|
205
|
+
return caddis_exit.NOT_RUN
|
|
206
|
+
print(plan)
|
|
207
|
+
return 0
|
|
525
208
|
|
|
526
209
|
|
|
527
210
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""caddis_upstream_check - say when an upstream skill that caddis borrowed ideas from has changed.
|
|
3
|
+
|
|
4
|
+
Why: /caddis:feature-plan and /caddis:implement adapt ideas from obra/superpowers (writing-plans 7.0.0,
|
|
5
|
+
subagent-driven-development 7.0.0, MIT). `.github/upstream-sources.json` records, per borrowed file, the
|
|
6
|
+
upstream version and the sha256 of the file we read. This script compares that record with the copies
|
|
7
|
+
installed in the Claude Code plugin cache (`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/<path>`),
|
|
8
|
+
so caddis does not fall behind upstream without anyone seeing it. /caddis:usage-review runs it.
|
|
9
|
+
|
|
10
|
+
One line per source:
|
|
11
|
+
UP-TO-DATE <id> <version>
|
|
12
|
+
DRIFT <id> recorded <v> installed <v'> (sha differs): review the upstream diff and port what fits, ...
|
|
13
|
+
NOT-INSTALLED <id>
|
|
14
|
+
`NO-SOURCES <path>` means there is no record (any repo other than the caddis source repo).
|
|
15
|
+
|
|
16
|
+
Advisory only: the exit code is always 0. Stdlib only.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python caddis_upstream_check.py [--sources <json>] [--cache-root <dir>] [--json]
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
SOURCES_REL = Path(".github") / "upstream-sources.json"
|
|
30
|
+
DRIFT_ADVICE = "review the upstream diff and port what fits, then update .github/upstream-sources.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_sources() -> Path:
|
|
34
|
+
"""The record in the current repo; else the one next to this script in a source checkout."""
|
|
35
|
+
here = Path.cwd() / SOURCES_REL
|
|
36
|
+
if here.is_file():
|
|
37
|
+
return here
|
|
38
|
+
return Path(__file__).resolve().parents[1] / SOURCES_REL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_cache_root() -> Path:
|
|
42
|
+
return Path.home() / ".claude" / "plugins" / "cache"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version_key(version: str) -> tuple:
|
|
46
|
+
"""Numeric parts compare as numbers (7.10.0 > 7.9.0); a non-numeric part sorts before any number."""
|
|
47
|
+
return tuple((1, int(p)) if p.isdigit() else (0, p) for p in version.split("."))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sha256(path: Path) -> str:
|
|
51
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def installed_copies(cache_root: Path, plugin: str, rel_path: str) -> list[tuple[str, Path]]:
|
|
55
|
+
"""(version, file) for every installed copy, newest version last."""
|
|
56
|
+
if not cache_root.is_dir():
|
|
57
|
+
return []
|
|
58
|
+
found = [(f.parents[len(Path(rel_path).parts) - 1].name, f)
|
|
59
|
+
for f in cache_root.glob(f"*/{plugin}/*/{rel_path}") if f.is_file()]
|
|
60
|
+
return sorted(found, key=lambda vf: _version_key(vf[0]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_source(src: dict, cache_root: Path) -> dict:
|
|
64
|
+
sid, recorded = src["id"], src["version"]
|
|
65
|
+
plugin = sid.split(":", 1)[0]
|
|
66
|
+
copies = installed_copies(cache_root, plugin, src["path"])
|
|
67
|
+
row = {"id": sid, "status": "NOT-INSTALLED", "recorded_version": recorded, "installed_version": None,
|
|
68
|
+
"line": f"NOT-INSTALLED {sid}"}
|
|
69
|
+
if not copies:
|
|
70
|
+
return row
|
|
71
|
+
version, newest = copies[-1]
|
|
72
|
+
row["installed_version"] = version
|
|
73
|
+
if _sha256(newest) == src["sha256"]:
|
|
74
|
+
row.update(status="UP-TO-DATE", line=f"UP-TO-DATE {sid} {recorded}")
|
|
75
|
+
else:
|
|
76
|
+
row.update(status="DRIFT",
|
|
77
|
+
line=f"DRIFT {sid} recorded {recorded} installed {version} (sha differs): {DRIFT_ADVICE}")
|
|
78
|
+
return row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: list[str] | None = None) -> int:
|
|
82
|
+
ap = argparse.ArgumentParser(description="Compare borrowed upstream skills with the installed copies.")
|
|
83
|
+
ap.add_argument("--sources", type=Path, default=None, help="path to upstream-sources.json")
|
|
84
|
+
ap.add_argument("--cache-root", type=Path, default=None, help="the Claude Code plugin cache folder")
|
|
85
|
+
ap.add_argument("--json", action="store_true", help="print the rows as a JSON list")
|
|
86
|
+
args = ap.parse_args(argv)
|
|
87
|
+
sources = args.sources or default_sources()
|
|
88
|
+
cache_root = args.cache_root or default_cache_root()
|
|
89
|
+
if not sources.is_file():
|
|
90
|
+
print(f"NO-SOURCES {sources}")
|
|
91
|
+
return 0
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(sources.read_text(encoding="utf-8"))
|
|
94
|
+
rows = [check_source(src, cache_root) for src in data.get("sources", [])]
|
|
95
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
96
|
+
print(f"NO-SOURCES {sources} (unreadable: {exc})")
|
|
97
|
+
return 0
|
|
98
|
+
if args.json:
|
|
99
|
+
print(json.dumps(rows, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
for row in rows:
|
|
102
|
+
print(row["line"])
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "caddis hub coordinator state (coordinator.json)",
|
|
4
|
+
"description": "One file per repo id, written only by caddis_coordinate.py. Readers accept schema N and N-1.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema", "repo", "coordinator_session", "heartbeat_at", "queue", "merging_now", "deploy_checks",
|
|
7
|
+
"ledger", "processed_requests"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema": {"type": "integer", "enum": [0, 1]},
|
|
10
|
+
"repo": {"type": "string", "minLength": 1},
|
|
11
|
+
"coordinator_session": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["name", "sessionId", "pid"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"name": {"type": ["string", "null"]},
|
|
16
|
+
"sessionId": {"type": ["string", "null"]},
|
|
17
|
+
"pid": {"type": ["integer", "null"]}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"heartbeat_at": {"$ref": "#/$defs/utc"},
|
|
21
|
+
"queue": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"items": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"required": ["pr", "head_sha", "title", "state", "why_waiting", "waits_on", "hotfix", "held",
|
|
26
|
+
"entered_ready_at"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"pr": {"type": "integer", "minimum": 1},
|
|
29
|
+
"head_sha": {"type": "string"},
|
|
30
|
+
"title": {"type": "string"},
|
|
31
|
+
"state": {"enum": ["waiting", "ci", "ready", "merging", "blocked", "held"]},
|
|
32
|
+
"why_waiting": {"type": "string"},
|
|
33
|
+
"waits_on": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": ["repo", "pr"],
|
|
38
|
+
"properties": {"repo": {"type": "string"}, "pr": {"type": "integer"}}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"hotfix": {"type": "boolean"},
|
|
42
|
+
"held": {"type": "boolean"},
|
|
43
|
+
"entered_ready_at": {"anyOf": [{"$ref": "#/$defs/utc"}, {"type": "null"}]}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"merging_now": {"type": ["integer", "object", "null"]},
|
|
48
|
+
"deploy_checks": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["pr", "merge_sha", "deploy_run", "health", "at"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"pr": {"type": "integer"},
|
|
55
|
+
"merge_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "lower-cased"},
|
|
56
|
+
"deploy_run": {"type": ["integer", "null"]},
|
|
57
|
+
"health": {"enum": ["pending", "ok", "failed"]},
|
|
58
|
+
"at": {"$ref": "#/$defs/utc"}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"ledger": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"required": ["what", "decision", "approved_by", "reason", "window", "request_id", "at"],
|
|
67
|
+
"properties": {
|
|
68
|
+
"what": {"type": "string", "minLength": 1},
|
|
69
|
+
"decision": {"enum": ["approve", "decline"]},
|
|
70
|
+
"approved_by": {"enum": ["owner", "coordinator"]},
|
|
71
|
+
"reason": {"type": "string", "minLength": 1},
|
|
72
|
+
"window": {"type": ["string", "null"], "description": "the session window the decision was made in; \"hub\" for an owner click"},
|
|
73
|
+
"request_id": {"type": ["string", "null"], "description": "the owner request id; null when not from a request"},
|
|
74
|
+
"at": {"$ref": "#/$defs/utc"}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"processed_requests": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"items": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"required": ["id", "result", "reason", "at"],
|
|
83
|
+
"properties": {
|
|
84
|
+
"id": {"type": "string"},
|
|
85
|
+
"result": {"enum": ["done", "rejected", "expired"]},
|
|
86
|
+
"reason": {"type": "string"},
|
|
87
|
+
"at": {"$ref": "#/$defs/utc"}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"$defs": {
|
|
93
|
+
"utc": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"}
|
|
94
|
+
}
|
|
95
|
+
}
|