@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.
Files changed (24) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_budget.py +0 -38
  3. package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +1083 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +5 -6
  5. package/bundles/antigravity-plugin/scripts/caddis_rows.py +10 -451
  6. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +6 -7
  7. package/bundles/antigravity-plugin/scripts/caddis_todo.py +35 -352
  8. package/bundles/antigravity-plugin/scripts/caddis_upstream_check.py +107 -0
  9. package/bundles/antigravity-plugin/scripts/schemas/coordinator.schema.json +95 -0
  10. package/bundles/antigravity-plugin/scripts/schemas/request.schema.json +39 -0
  11. package/bundles/antigravity-plugin/skills/_registry.md +1 -1
  12. package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +1 -1
  13. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +64 -1
  14. package/bundles/antigravity-plugin/skills/flow-studio/README.md +1 -1
  15. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +25 -39
  16. package/bundles/antigravity-plugin/skills/implement/SKILL.md +14 -0
  17. package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +9 -0
  18. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +15 -375
  19. package/bundles/antigravity-plugin/warm_start_agy.py +3 -40
  20. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +15 -375
  21. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +1 -1
  22. package/bundles/codex/.codex/skills/workflow/brainstorming/SKILL.md +1 -1
  23. package/bundles/manifest.json +2 -2
  24. package/package.json +1 -1
@@ -1,29 +1,32 @@
1
- """Locked, local to-do lists and a read-only view of active plan work."""
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 caddis_file_lock import atomic_write, locked_path, retry_denied
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 caddis_file_lock import atomic_write, locked_path, retry_denied
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
- # Trailing status markers on phase headings: arrows/clocks (U+2300-U+27BF), emoji, VS16.
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 the open-work view and caddis_tidy never disagree about the same table.
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
- # Titles carry non-ASCII text; a Windows console defaults to a legacy code page.
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
- args = parser.parse_args(argv)
484
- try:
485
- if args.command == "add":
486
- item = add(args.root, args.text, args.workstream, resolves=args.resolves, partial=args.partial)
487
- print(item["id"])
488
- if not args.resolves:
489
- for suggestion in _suggest(args.root, item["title"]):
490
- print(f"Suggested parking item: {suggestion}")
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
- except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
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
+ }