@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.
- package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- 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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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",
|