@caddis/cli 0.4.52 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -225,6 +225,43 @@ def _age(when: datetime, now: datetime) -> str:
225
225
  return f"{seconds // 86400}d"
226
226
 
227
227
 
228
+ def _relays(directory: Path) -> list[tuple[str, dict, str]]:
229
+ out: list[tuple[str, dict, str]] = []
230
+ for path in sorted(directory.glob("*.md")) if directory.is_dir() else ():
231
+ if path.is_symlink():
232
+ continue
233
+ try:
234
+ name = _name(path.stem)
235
+ if not path.resolve().is_relative_to(directory.resolve()):
236
+ continue
237
+ content = path.read_bytes().decode("utf-8")
238
+ meta = parse(content)
239
+ if meta.get("type") != "relay" or meta.get("workstream") != name:
240
+ continue
241
+ generated = meta.get("generated")
242
+ datetime.fromisoformat(generated["at"].replace("Z", "+00:00"))
243
+ out.append((name, meta, content))
244
+ except (ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
245
+ continue
246
+ return out
247
+
248
+
249
+ def _item_matches(item: str, next_text: str) -> bool:
250
+ needle = item.strip()
251
+ if not needle:
252
+ return False
253
+ lower_next = next_text.lower()
254
+ if needle.lower() in lower_next:
255
+ return True
256
+ m = re.fullmatch(r"(.+)#(\d+)", needle)
257
+ if m:
258
+ plan_slug = m.group(1).strip().lower()
259
+ phase_num = m.group(2)
260
+ if plan_slug and plan_slug in lower_next and re.search(rf"\bphase\s+{re.escape(phase_num)}\b", next_text, re.IGNORECASE):
261
+ return True
262
+ return False
263
+
264
+
228
265
  def render_index(root: Path, session_id: str | None, branch: str | None) -> str:
229
266
  """Write and return the bounded startup index from current local files."""
230
267
  artifact = _artifact(root)
@@ -240,25 +277,15 @@ def render_index(root: Path, session_id: str | None, branch: str | None) -> str:
240
277
  directory = artifact / "relay"
241
278
  if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
242
279
  raise ValueError("relay directory escapes artifact root")
243
- for path in directory.glob("*.md") if directory.is_dir() else ():
244
- if path.is_symlink():
245
- continue
246
- try:
247
- name = _name(path.stem)
248
- content, _ = read_relay(root, name)
249
- meta = parse(content)
250
- if meta.get("type") != "relay" or meta.get("workstream") != name:
251
- continue
252
- generated = meta.get("generated")
253
- at = datetime.fromisoformat(generated["at"].replace("Z", "+00:00"))
254
- if at.tzinfo is None:
255
- at = at.replace(tzinfo=timezone.utc)
256
- claimed_sessions.add(str(meta.get("session_id", "")))
257
- label = _single_line(name, 80)
258
- branch_label = _single_line(str(meta.get("branch", "?")), 50)
259
- rows.append((at.timestamp(), f"- {label} ({branch_label}, {_age(at, now)}): {_next_step(content)}"))
260
- except (ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
261
- continue
280
+ for name, meta, content in _relays(directory):
281
+ generated = meta["generated"]
282
+ at = datetime.fromisoformat(generated["at"].replace("Z", "+00:00"))
283
+ if at.tzinfo is None:
284
+ at = at.replace(tzinfo=timezone.utc)
285
+ claimed_sessions.add(str(meta.get("session_id", "")))
286
+ label = _single_line(name, 80)
287
+ branch_label = _single_line(str(meta.get("branch", "?")), 50)
288
+ rows.append((at.timestamp(), f"- {label} ({branch_label}, {_age(at, now)}): {_next_step(content)}"))
262
289
  unnamed: list[tuple[datetime, str, str, str]] = []
263
290
  states = artifact / "session-state"
264
291
  if states.exists() and not states.resolve().is_relative_to(artifact.resolve()):
@@ -315,6 +342,36 @@ def render_index(root: Path, session_id: str | None, branch: str | None) -> str:
315
342
  return result
316
343
 
317
344
 
345
+ def parse_status(text: str) -> tuple[str, str]:
346
+ """Parse the terminal status line from a relay body."""
347
+ body = split_document(text)[1]
348
+ status_lines = [
349
+ line.strip()
350
+ for line in body.splitlines()
351
+ if line.strip().lower().startswith("status:")
352
+ ]
353
+ if len(status_lines) > 1:
354
+ raise ValueError("two or more status lines")
355
+
356
+ non_empty = [line.strip() for line in body.splitlines() if line.strip()]
357
+ if not non_empty:
358
+ raise ValueError("missing status line")
359
+
360
+ last = non_empty[-1]
361
+ if last == "STATUS: CONTINUE":
362
+ return "CONTINUE", ""
363
+ m = re.fullmatch(r"STATUS: OWNER-GATE ([A-Z]{2,4}-OG\d+)", last)
364
+ if m:
365
+ return "OWNER-GATE", m.group(1)
366
+ m = re.fullmatch(r"STATUS: BLOCKED (.+)", last)
367
+ if m and m.group(1).strip():
368
+ return "BLOCKED", m.group(1).strip()
369
+ m = re.fullmatch(r"STATUS: FAILED (.+)", last)
370
+ if m and m.group(1).strip():
371
+ return "FAILED", m.group(1).strip()
372
+ raise ValueError(f"invalid or missing status line: {last!r}")
373
+
374
+
318
375
  class MigrationReport(dict):
319
376
  """Report describing the outcome of a legacy migration."""
320
377
 
@@ -596,6 +653,20 @@ def main(argv: list[str] | None = None) -> int:
596
653
  write_p.add_argument("--expected-hash", help="hash from `hash`; 'none' when the relay must not exist yet")
597
654
  write_p.add_argument("--root", type=Path, default=Path("."))
598
655
 
656
+ status_p = sub.add_parser("status")
657
+ status_p.add_argument("name")
658
+ status_p.add_argument("--root", type=Path, default=Path("."))
659
+ status_p.add_argument("--json", action="store_true")
660
+
661
+ put_p = sub.add_parser("put")
662
+ put_p.add_argument("name")
663
+ put_p.add_argument("file", type=Path)
664
+ put_p.add_argument("--expected-hash")
665
+ put_p.add_argument("--require-status", action="store_true")
666
+ put_p.add_argument("--session-id")
667
+ put_p.add_argument("--branch")
668
+ put_p.add_argument("--root", type=Path, default=Path("."))
669
+
599
670
  render_p = sub.add_parser("render-index")
600
671
  render_p.add_argument("--session-id")
601
672
  render_p.add_argument("--branch")
@@ -604,6 +675,14 @@ def main(argv: list[str] | None = None) -> int:
604
675
  mig_p = sub.add_parser("migrate-legacy")
605
676
  mig_p.add_argument("--root", type=Path, default=Path("."))
606
677
 
678
+ list_p = sub.add_parser("list", help="list each relay's name, branch and next step")
679
+ list_p.add_argument("--root", type=Path, default=Path("."))
680
+
681
+ mentions_p = sub.add_parser("mentions", help="list other relays whose Next step mentions an item")
682
+ mentions_p.add_argument("--item", required=True)
683
+ mentions_p.add_argument("--except", dest="except_name")
684
+ mentions_p.add_argument("--root", type=Path, default=Path("."))
685
+
607
686
  args = parser.parse_args(argv)
608
687
  if args.command == "resolve":
609
688
  name = resolve_workstream(args.plan, args.branch, args.default_branch, args.suggest)
@@ -628,6 +707,64 @@ def main(argv: list[str] | None = None) -> int:
628
707
  return 1
629
708
  print(res.content_hash)
630
709
  return 0
710
+ elif args.command == "status":
711
+ try:
712
+ text, digest = read_relay(args.root, args.name)
713
+ except ValueError as exc:
714
+ sys.stderr.write(f"cannot read relay: {exc}\n")
715
+ return 1
716
+ if digest is None:
717
+ sys.stderr.write(f"relay {args.name!r} not found\n")
718
+ return 1
719
+ try:
720
+ kind, arg = parse_status(text)
721
+ except ValueError as exc:
722
+ sys.stderr.write(f"invalid relay status: {exc}\n")
723
+ return 1
724
+ if args.json:
725
+ print(json.dumps({"status": kind, "arg": arg}))
726
+ else:
727
+ if arg:
728
+ print(f"STATUS: {kind} {arg}")
729
+ else:
730
+ print(f"STATUS: {kind}")
731
+ return 0
732
+ elif args.command == "put":
733
+ try:
734
+ content = args.file.read_text(encoding="utf-8")
735
+ except (OSError, UnicodeDecodeError) as exc:
736
+ sys.stderr.write(f"cannot read file: {exc}\n")
737
+ return 1
738
+ if args.require_status:
739
+ try:
740
+ parse_status(content)
741
+ except ValueError as exc:
742
+ sys.stderr.write(f"relay rejected: {exc}\n")
743
+ return 1
744
+ if args.expected_hash is not None:
745
+ expected = None if args.expected_hash == "none" else args.expected_hash
746
+ else:
747
+ try:
748
+ _, expected = read_relay(args.root, args.name)
749
+ except ValueError as exc:
750
+ sys.stderr.write(f"cannot read existing relay: {exc}\n")
751
+ return 1
752
+ try:
753
+ res = write_relay(args.root, args.name, content, expected)
754
+ except ValueError as exc:
755
+ sys.stderr.write(f"invalid relay: {exc}\n")
756
+ return 1
757
+ if res.status != "written":
758
+ sys.stdout.write(res.diff + ("\n" if not res.diff.endswith("\n") else ""))
759
+ sys.stderr.write("relay not written: the stored relay changed since you read it (diff above)\n")
760
+ return 1
761
+ try:
762
+ render_index(args.root, args.session_id, args.branch)
763
+ except (OSError, ValueError) as exc:
764
+ # The relay is written; only the startup index is stale. Say so, but do not report a failed put.
765
+ sys.stderr.write(f"relay written, but the index was not rendered: {exc}\n")
766
+ print(res.content_hash)
767
+ return 0
631
768
  elif args.command == "render-index":
632
769
  render_index(args.root, args.session_id, args.branch)
633
770
  return 0
@@ -637,6 +774,28 @@ def main(argv: list[str] | None = None) -> int:
637
774
  for conflict in report.conflicts:
638
775
  print(f" conflict: {conflict}")
639
776
  return 0 if report.status in ("clean", "migrated") else 1
777
+ elif args.command == "list":
778
+ artifact = _artifact(args.root)
779
+ directory = artifact / "relay"
780
+ if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
781
+ raise ValueError("relay directory escapes artifact root")
782
+ for name, meta, content in _relays(directory):
783
+ branch_label = _single_line(str(meta.get("branch", "?")), 50)
784
+ print(f"{name}\t{branch_label}\t{_next_step(content)}")
785
+ return 0
786
+ elif args.command == "mentions":
787
+ artifact = _artifact(args.root)
788
+ directory = artifact / "relay"
789
+ if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
790
+ raise ValueError("relay directory escapes artifact root")
791
+ skip = args.except_name.lower() if args.except_name else None
792
+ for name, _meta, content in _relays(directory):
793
+ if skip and name.lower() == skip: # names compare case-insensitively
794
+ continue
795
+ match = _NEXT.search(content)
796
+ if match and _item_matches(args.item, match.group(1)):
797
+ print(name)
798
+ return 0
640
799
  parser.print_help()
641
800
  return 2
642
801
 
@@ -0,0 +1,349 @@
1
+ """Close a lane's git worktree after its PR merged: compare content, then remove the worktree and its branches.
2
+
3
+ Refuses (exit 1) when the PR is not merged, the worktree has commits the PR head does not contain, the merged files differ
4
+ from the PR head, a named port still listens, the worktree is dirty, or the worktree holds a junction or symlink. The PR
5
+ may be a combined release PR: the lane branch need not be the PR's head branch, only contained in its head. The remote
6
+ branch is deleted only when its tip is contained in the PR head; a branch someone pushed to after the merge is kept,
7
+ because deleting it would lose work that is not on main.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ import socket
17
+ import stat
18
+ import subprocess
19
+ import sys
20
+ from typing import Callable, Sequence
21
+
22
+ try:
23
+ import caddis_exit
24
+ except ModuleNotFoundError as exc:
25
+ if exc.name != "caddis_exit":
26
+ raise
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
+ import caddis_exit
30
+
31
+
32
+ def _is_symlink_or_reparse(p: Path | str) -> bool:
33
+ """Return True if path is a symlink or Windows reparse point."""
34
+ try:
35
+ path_obj = Path(p)
36
+ if os.path.islink(path_obj):
37
+ return True
38
+ st = os.lstat(path_obj)
39
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", None)
40
+ if reparse_flag is not None and hasattr(st, "st_file_attributes"):
41
+ if bool(st.st_file_attributes & reparse_flag):
42
+ return True
43
+ except OSError:
44
+ pass
45
+ return False
46
+
47
+
48
+ def _same_path(p1: str | Path, p2: str | Path) -> bool:
49
+ """Compare two paths for equality accounting for Windows semantics."""
50
+ try:
51
+ if Path(p1).resolve() == Path(p2).resolve():
52
+ return True
53
+ except Exception:
54
+ pass
55
+ return os.path.normcase(os.path.abspath(str(p1))) == os.path.normcase(os.path.abspath(str(p2)))
56
+
57
+
58
+ def close_worktree(
59
+ *,
60
+ repo: Path,
61
+ worktree: Path,
62
+ pr: int,
63
+ gh: Callable[[list[str]], str],
64
+ ports: Sequence[int] = (),
65
+ dry_run: bool = False,
66
+ ) -> int:
67
+ """Close a worktree after verifying PR merge and cleanliness."""
68
+ if _is_symlink_or_reparse(worktree):
69
+ # Checked on the path as given: resolve() would follow the junction and hide it.
70
+ print(f"refuse: worktree folder {worktree} is a junction or symlink; deleting through it destroys its target")
71
+ return caddis_exit.BLOCKED
72
+ repo = Path(repo).resolve()
73
+ worktree = Path(worktree).resolve()
74
+
75
+ # 1. Resolve worktree and find it in git -C repo worktree list --porcelain
76
+ proc = subprocess.run(
77
+ ["git", "-C", str(repo), "worktree", "list", "--porcelain"],
78
+ capture_output=True,
79
+ text=True,
80
+ encoding="utf-8",
81
+ errors="replace",
82
+ )
83
+ if proc.returncode != 0:
84
+ print(f"git worktree list failed: {proc.stderr.strip()}", file=sys.stderr)
85
+ return caddis_exit.ERROR
86
+
87
+ blocks: list[dict[str, str]] = []
88
+ current_block: dict[str, str] = {}
89
+ for line in proc.stdout.splitlines():
90
+ line = line.strip()
91
+ if not line:
92
+ if current_block:
93
+ blocks.append(current_block)
94
+ current_block = {}
95
+ continue
96
+ if line.startswith("worktree "):
97
+ if current_block:
98
+ blocks.append(current_block)
99
+ current_block = {}
100
+ current_block["worktree"] = line[len("worktree "):].strip()
101
+ elif line.startswith("HEAD "):
102
+ current_block["HEAD"] = line[len("HEAD "):].strip()
103
+ elif line.startswith("branch "):
104
+ current_block["branch"] = line[len("branch "):].strip()
105
+ elif line == "detached":
106
+ current_block["detached"] = "true"
107
+ if current_block:
108
+ blocks.append(current_block)
109
+
110
+ if not blocks:
111
+ print(f"No worktrees found for repo {repo}", file=sys.stderr)
112
+ return caddis_exit.ERROR
113
+
114
+ # First entry is the main checkout
115
+ if _same_path(blocks[0].get("worktree", ""), worktree):
116
+ print(f"refuse: {worktree} is the main checkout (first entry); will not remove", file=sys.stderr)
117
+ return caddis_exit.ERROR
118
+
119
+ target_block: dict[str, str] | None = None
120
+ for b in blocks[1:]:
121
+ if _same_path(b.get("worktree", ""), worktree):
122
+ target_block = b
123
+ break
124
+
125
+ if target_block is None:
126
+ print(f"refuse: {worktree} is not listed in worktrees of {repo}", file=sys.stderr)
127
+ return caddis_exit.ERROR
128
+
129
+ branch_ref = target_block.get("branch", "")
130
+ if branch_ref.startswith("refs/heads/"):
131
+ branch_name = branch_ref[len("refs/heads/"):]
132
+ elif branch_ref:
133
+ branch_name = branch_ref
134
+ else:
135
+ print(f"refuse: worktree {worktree} has no branch (detached)", file=sys.stderr)
136
+ return caddis_exit.ERROR
137
+
138
+ # 2. gh pr view
139
+ try:
140
+ raw_json = gh(["pr", "view", str(pr), "--json", "state,mergeCommit,headRefOid,headRefName"])
141
+ pr_data = json.loads(raw_json)
142
+ except Exception as exc:
143
+ detail = getattr(exc, "stderr", "") or ""
144
+ print(f"refuse: failed to view PR #{pr}: {exc} {detail}".rstrip())
145
+ return caddis_exit.BLOCKED
146
+
147
+ state = pr_data.get("state")
148
+ merge_commit = pr_data.get("mergeCommit")
149
+ merge_oid = merge_commit.get("oid") if isinstance(merge_commit, dict) else None
150
+ head_ref_oid = pr_data.get("headRefOid")
151
+
152
+ if state != "MERGED" or not merge_oid:
153
+ print(f"refuse: PR #{pr} is not merged")
154
+ return caddis_exit.BLOCKED
155
+
156
+ # 3. git fetch origin --prune; git fetch origin <headRefOid>; cat-file
157
+ subprocess.run(["git", "-C", str(repo), "fetch", "-q", "origin", "--prune"], capture_output=True, text=True, encoding="utf-8", errors="replace")
158
+ if head_ref_oid:
159
+ subprocess.run(["git", "-C", str(repo), "fetch", "-q", "origin", str(head_ref_oid)], capture_output=True, text=True, encoding="utf-8", errors="replace")
160
+
161
+ proc = subprocess.run(
162
+ ["git", "-C", str(repo), "cat-file", "-e", f"{head_ref_oid}^{{commit}}"],
163
+ capture_output=True,
164
+ text=True,
165
+ encoding="utf-8",
166
+ errors="replace",
167
+ )
168
+ if proc.returncode != 0:
169
+ print(f"refuse: PR head commit {head_ref_oid} not found in repo")
170
+ return caddis_exit.BLOCKED
171
+
172
+ # 4. Unpushed work
173
+ proc = subprocess.run(
174
+ ["git", "-C", str(worktree), "rev-parse", "HEAD"],
175
+ capture_output=True,
176
+ text=True,
177
+ encoding="utf-8",
178
+ errors="replace",
179
+ )
180
+ if proc.returncode != 0:
181
+ print(f"refuse: failed to get HEAD of worktree {worktree}")
182
+ return caddis_exit.BLOCKED
183
+
184
+ wt_head = proc.stdout.strip()
185
+ if wt_head != head_ref_oid:
186
+ proc_ancestor = subprocess.run(
187
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", wt_head, str(head_ref_oid)],
188
+ capture_output=True,
189
+ text=True,
190
+ )
191
+ if proc_ancestor.returncode != 0:
192
+ print("refuse: worktree has commits the PR does not")
193
+ return caddis_exit.BLOCKED
194
+
195
+ # 5. Content compare
196
+ proc = subprocess.run(
197
+ ["git", "-C", str(repo), "diff", "--name-only", f"{merge_oid}^", merge_oid],
198
+ capture_output=True,
199
+ text=True,
200
+ encoding="utf-8",
201
+ errors="replace",
202
+ )
203
+ if proc.returncode != 0:
204
+ print(f"git diff failed for {merge_oid}^ {merge_oid}: {proc.stderr.strip()}", file=sys.stderr)
205
+ return caddis_exit.ERROR
206
+
207
+ files = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
208
+ files_compared = len(files)
209
+ if files:
210
+ proc_diff = subprocess.run(
211
+ ["git", "-C", str(repo), "diff", "--quiet", str(head_ref_oid), merge_oid, "--", *files],
212
+ capture_output=True,
213
+ text=True,
214
+ )
215
+ if proc_diff.returncode != 0:
216
+ print("refuse: PR head differs from the merged content; not deleted")
217
+ return caddis_exit.BLOCKED
218
+
219
+ # 6. Ports
220
+ for port in ports:
221
+ try:
222
+ with socket.create_connection(("127.0.0.1", int(port)), timeout=0.5):
223
+ print(f"refuse: a server still listens on {port}")
224
+ return caddis_exit.BLOCKED
225
+ except (OSError, socket.timeout):
226
+ pass
227
+
228
+ # 7. Dirty
229
+ proc = subprocess.run(
230
+ ["git", "-C", str(worktree), "status", "--porcelain", "--untracked-files=all"],
231
+ capture_output=True,
232
+ text=True,
233
+ encoding="utf-8",
234
+ errors="replace",
235
+ )
236
+ if proc.returncode != 0:
237
+ print(f"git status failed on worktree {worktree}: {proc.stderr.strip()}", file=sys.stderr)
238
+ return caddis_exit.ERROR
239
+
240
+ dirty = proc.stdout.strip()
241
+ if dirty:
242
+ print(f"refuse: worktree {worktree} is dirty:\n{dirty}")
243
+ return caddis_exit.BLOCKED
244
+
245
+ # 8. Junction or symlink guard
246
+ if _is_symlink_or_reparse(worktree):
247
+ print(f"refuse: worktree folder {worktree} is a junction or symlink; deleting through a junction destroys its target")
248
+ return caddis_exit.BLOCKED
249
+
250
+ if worktree.is_dir():
251
+ try:
252
+ for item in worktree.iterdir():
253
+ if _is_symlink_or_reparse(item):
254
+ print(f"refuse: entry {item} inside worktree is a junction or symlink; deleting through a junction destroys its target")
255
+ return caddis_exit.BLOCKED
256
+ except OSError as exc:
257
+ print(f"refuse: failed to inspect worktree {worktree}: {exc}")
258
+ return caddis_exit.BLOCKED
259
+
260
+ # 9. dry_run
261
+ if dry_run:
262
+ print(f"would remove {worktree}, branch {branch_name} (local and origin)")
263
+ return caddis_exit.CLEAN
264
+
265
+ # 10. Remove
266
+ cmd_wt_rm = ["git", "-C", str(repo), "worktree", "remove", "--force", str(worktree)]
267
+ proc = subprocess.run(cmd_wt_rm, capture_output=True, text=True, encoding="utf-8", errors="replace")
268
+ if proc.returncode != 0:
269
+ print(f"failed command: {' '.join(cmd_wt_rm)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
270
+ return caddis_exit.ERROR
271
+
272
+ cmd_br_d = ["git", "-C", str(repo), "branch", "-D", branch_name]
273
+ proc = subprocess.run(cmd_br_d, capture_output=True, text=True, encoding="utf-8", errors="replace")
274
+ if proc.returncode != 0:
275
+ print(f"failed command: {' '.join(cmd_br_d)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
276
+ return caddis_exit.ERROR
277
+
278
+ cmd_ls = ["git", "-C", str(repo), "ls-remote", "--exit-code", "--heads", "origin", branch_name]
279
+ proc_ls = subprocess.run(cmd_ls, capture_output=True, text=True, encoding="utf-8", errors="replace")
280
+ if proc_ls.returncode not in (0, 2): # 2 = no such branch; anything else = origin could not be read
281
+ print(f"WARNING: could not read origin (ls-remote exit {proc_ls.returncode}); origin/{branch_name} not deleted")
282
+ remote_tip = proc_ls.stdout.split()[0] if proc_ls.returncode == 0 and proc_ls.stdout.split() else ""
283
+ contained = remote_tip == head_ref_oid or (remote_tip and subprocess.run(
284
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", remote_tip, str(head_ref_oid)],
285
+ capture_output=True, text=True, encoding="utf-8", errors="replace").returncode == 0)
286
+ if remote_tip and not contained:
287
+ # Someone pushed after the merge: that work is not in main. Keep the remote branch.
288
+ print(f"kept origin/{branch_name}: its tip {remote_tip[:12]} is not contained in the merged PR head {str(head_ref_oid)[:12]}")
289
+ elif remote_tip:
290
+ cmd_push_del = ["git", "-C", str(repo), "push", "-q", "origin", "--delete", branch_name]
291
+ proc = subprocess.run(cmd_push_del, capture_output=True, text=True, encoding="utf-8", errors="replace")
292
+ if proc.returncode != 0:
293
+ print(f"failed command: {' '.join(cmd_push_del)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
294
+ return caddis_exit.ERROR
295
+
296
+ cmd_prune = ["git", "-C", str(repo), "worktree", "prune"]
297
+ proc = subprocess.run(cmd_prune, capture_output=True, text=True, encoding="utf-8", errors="replace")
298
+ if proc.returncode != 0:
299
+ print(f"failed command: {' '.join(cmd_prune)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
300
+ return caddis_exit.ERROR
301
+
302
+ print(f"removed {worktree} ({branch_name}); merge {merge_oid}; files compared: {files_compared}")
303
+ return caddis_exit.CLEAN
304
+
305
+
306
+ def main(argv: list[str] | None = None) -> int:
307
+ """CLI entry point for caddis_worktree_close."""
308
+ if argv is None:
309
+ argv = sys.argv[1:]
310
+
311
+ parser = argparse.ArgumentParser(description="Close a worktree after PR merge.")
312
+ parser.add_argument("--repo", required=True, type=Path, help="Path to main repository")
313
+ parser.add_argument("--worktree", required=True, type=Path, help="Path to worktree to close")
314
+ parser.add_argument("--pr", required=True, type=int, help="PR number")
315
+ parser.add_argument(
316
+ "--port",
317
+ dest="ports",
318
+ type=int,
319
+ nargs="+",
320
+ action="extend",
321
+ default=[],
322
+ help="Ports to check for listening servers",
323
+ )
324
+ parser.add_argument("--dry-run", action="store_true", default=False, help="Dry run mode")
325
+
326
+ args = parser.parse_args(argv)
327
+
328
+ def real_gh(gh_args: list[str]) -> str:
329
+ proc = subprocess.run(
330
+ ["gh", *gh_args],
331
+ cwd=str(args.repo),
332
+ capture_output=True,
333
+ text=True,
334
+ check=True,
335
+ )
336
+ return proc.stdout
337
+
338
+ return close_worktree(
339
+ repo=args.repo,
340
+ worktree=args.worktree,
341
+ pr=args.pr,
342
+ gh=real_gh,
343
+ ports=tuple(args.ports),
344
+ dry_run=args.dry_run,
345
+ )
346
+
347
+
348
+ if __name__ == "__main__":
349
+ sys.exit(main())
@@ -38,7 +38,6 @@ from __future__ import annotations
38
38
  import argparse
39
39
  import os
40
40
  import re
41
- import subprocess
42
41
  import sys
43
42
  from pathlib import Path
44
43
 
@@ -282,54 +281,6 @@ def _split_list_entries(nested: list[str]) -> list[str]:
282
281
  return entries
283
282
 
284
283
 
285
- HUB_REPORTS_DIR = ".caddis/advisory-hub-reports"
286
-
287
-
288
- def misfiled_hub_spawns(root: Path) -> list[str]:
289
- """Files whose FRONTMATTER declares `type: hub-spawn` but which live outside the reports dir.
290
-
291
- `/caddis:spawn-hub` states the correct path, justifies it, and is gated — but the gate only
292
- runs INSIDE the command. An agent asked for a handover "basically like what we do with caddis
293
- spawn-hub" hand-wrote one without invoking it, never read the instruction, and filed the
294
- artefact in `.caddis/prompts/` because that is where the project's generic prompts live. The
295
- mechanism was self-enforcing only for those already using it.
296
-
297
- The knock-on is the sharp part: spawn-hub's own trap-rotation step reads the last spawn prompt
298
- from the reports dir to pick a fresh trap. A prompt filed elsewhere is invisible, so rotation
299
- silently runs against an empty set — the exact failure the artefact file was introduced to
300
- prevent, returning through a different door.
301
-
302
- Frontmatter only, never a prose match: this repo's own documentation says `type: hub-spawn` in
303
- running text, and flagging that would make the check permanently red where it is authored.
304
- """
305
- out: list[str] = []
306
- try:
307
- # --others --exclude-standard is load-bearing: a hand-rolled spawn prompt is UNTRACKED at
308
- # the exact moment it is misfiled, and a plain `git ls-files` lists only tracked files. The
309
- # first version of this check used one, so it passed cleanly on a live misfiled artefact —
310
- # a check that cannot fail, guarding the case it was written for.
311
- listed = subprocess.run(
312
- ["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.md"],
313
- cwd=str(root), capture_output=True, text=True, timeout=30)
314
- files = [root / p for p in listed.stdout.splitlines() if p.strip()] \
315
- if listed.returncode == 0 else []
316
- except Exception:
317
- files = []
318
- if not files: # no git, or an empty listing — fall back to the artefact tree
319
- files = sorted((root / ".caddis").rglob("*.md")) if (root / ".caddis").is_dir() else []
320
- for f in files:
321
- rel = f.relative_to(root).as_posix() if f.is_absolute() else str(f)
322
- if rel.startswith(HUB_REPORTS_DIR):
323
- continue
324
- try:
325
- meta = _header_fields(f.read_text(encoding="utf-8", errors="ignore"))
326
- except OSError:
327
- continue
328
- if str(meta.get("type", "")).strip().lower() == "hub-spawn":
329
- out.append(rel)
330
- return out
331
-
332
-
333
284
  def trust_signal_warnings(text: str) -> list[str]:
334
285
  """Well-formedness warnings for the OKF v0.2 trust signals present in ``text``. Pure.
335
286
 
@@ -917,13 +868,6 @@ def run(root: Path, check: bool) -> int:
917
868
  for issue in issues:
918
869
  warnings.append(f"{rel}: {issue}")
919
870
 
920
- # 5. Misfiled Hub artefacts (warning only).
921
- for rel in misfiled_hub_spawns(root):
922
- warnings.append(
923
- f"{rel} is a hub-spawn artefact outside {HUB_REPORTS_DIR} — /caddis:spawn-hub's "
924
- "trap rotation reads only that directory, so a prompt filed elsewhere is invisible "
925
- "to it and the next succession rotates against an empty set")
926
-
927
871
  for w in warnings:
928
872
  print(f" warning: {w}")
929
873
 
@@ -51,6 +51,7 @@ ENV_PREFIX = "CADDIS"
51
51
 
52
52
  # Known top-level document and working artifact folders under .caddis/
53
53
  KNOWN_CADDIS_DIRS: set[str] = {
54
+ "advisory-hub-reports", # retired 2026-10 (caddis-next Phase 12): kept as history, never a warning
54
55
  "plans",
55
56
  "prd",
56
57
  "kb",
@@ -64,7 +65,6 @@ KNOWN_CADDIS_DIRS: set[str] = {
64
65
  "prompts",
65
66
  "relay",
66
67
  "session-state",
67
- "advisory-hub-reports",
68
68
  "orchestrator",
69
69
  "spawn-session",
70
70
  "metrics",