@caddis/cli 0.4.55 → 0.4.57

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 (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ """Remove a git worktree, or a folder that held one, without deleting through a link.
2
+
3
+ On Windows, `git worktree remove --force` and `shutil.rmtree` can follow a directory junction and delete its
4
+ TARGET: a shared `.venv`, a `node_modules`, the live repo. So every symlink and junction in the tree goes first,
5
+ removed as a link (never entered), and only then is the tree removed. If a link cannot be removed, nothing is
6
+ deleted: the caller gets the problem back.
7
+
8
+ remove_links(root) -> the links removed (raises LinkRemovalError when one cannot go)
9
+ remove_tree(path) -> None, or the problem (links first, then rmtree)
10
+ safe_remove_worktree(repo, wt) -> None, or the problem (refuses the main checkout, unregistered paths, and
11
+ a locked or dirty worktree that git would keep, before any link goes)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ from collections.abc import Mapping, Sequence
21
+ from pathlib import Path
22
+
23
+ _REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
24
+ _DIRECTORY = getattr(stat, "FILE_ATTRIBUTE_DIRECTORY", 0x10)
25
+
26
+
27
+ class LinkRemovalError(OSError):
28
+ """A link could not be removed. `removed` holds the links that were already removed before the failure; no
29
+ other file was deleted."""
30
+
31
+ def __init__(self, cause: OSError, removed: list[Path]):
32
+ detail = str(cause)
33
+ if removed:
34
+ detail += (f" (removed {len(removed)} link(s) before the failure: "
35
+ f"{', '.join(str(p) for p in removed)})")
36
+ super().__init__(detail)
37
+ self.cause = cause
38
+ self.removed = removed
39
+
40
+
41
+ def is_link(st: os.stat_result) -> bool:
42
+ """A symlink, or on Windows any reparse point (a junction included), from `os.lstat`."""
43
+ return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & _REPARSE)
44
+
45
+
46
+ def _unlink_link(path: str, st: os.stat_result) -> None:
47
+ """Remove the link itself, never its target: on Windows a junction or a directory symlink with os.rmdir, any
48
+ other link with os.unlink."""
49
+ if os.name == "nt" and getattr(st, "st_file_attributes", 0) & _DIRECTORY:
50
+ os.rmdir(path)
51
+ else:
52
+ os.unlink(path)
53
+
54
+
55
+ def remove_links(root: Path) -> list[Path]:
56
+ """Remove every symlink and junction at or under `root`, bottom-up, never following one. Returns the links
57
+ removed. Raises LinkRemovalError (an OSError) when a link cannot be removed; the links removed before it are in
58
+ its `removed`. The caller must then delete nothing more, because a delete through a link destroys its target.
59
+ A missing `root` removes nothing."""
60
+ root = Path(root)
61
+ try:
62
+ st = os.lstat(root)
63
+ except FileNotFoundError:
64
+ return []
65
+ if is_link(st):
66
+ try:
67
+ _unlink_link(str(root), st)
68
+ except OSError as exc:
69
+ raise LinkRemovalError(exc, []) from exc
70
+ return [root]
71
+ folders = [str(root)]
72
+ order: list[str] = []
73
+ while folders: # top-down collection of real folders only; a link is never entered
74
+ folder = folders.pop()
75
+ order.append(folder)
76
+ with os.scandir(folder) as entries:
77
+ for entry in entries:
78
+ entry_st = os.lstat(entry.path)
79
+ if not is_link(entry_st) and stat.S_ISDIR(entry_st.st_mode):
80
+ folders.append(entry.path)
81
+ removed: list[Path] = []
82
+ try:
83
+ for folder in reversed(order): # bottom-up removal of the links in each folder
84
+ with os.scandir(folder) as entries:
85
+ found = [(entry.path, os.lstat(entry.path)) for entry in entries]
86
+ for path, entry_st in found:
87
+ if is_link(entry_st):
88
+ _unlink_link(path, entry_st)
89
+ removed.append(Path(path))
90
+ except OSError as exc:
91
+ raise LinkRemovalError(exc, removed) from exc
92
+ return removed
93
+
94
+
95
+ def remove_tree(path: Path) -> str | None:
96
+ """Delete the folder `path` (for example the temp folder that held a worktree): its links first, then the
97
+ rest. None when it is gone, else the problem. When a link cannot go, no file is deleted (the links removed
98
+ before the failure are named in the problem)."""
99
+ path = Path(path)
100
+ if not os.path.lexists(path):
101
+ return None
102
+ try:
103
+ remove_links(path)
104
+ except OSError as exc:
105
+ return f"not removed: {path}: a link could not be removed: {exc}; no file was deleted"
106
+ shutil.rmtree(path, ignore_errors=True)
107
+ return f"not removed: {path}" if os.path.lexists(path) else None
108
+
109
+
110
+ def _same(a: Path | str, b: Path | str) -> bool:
111
+ return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
112
+
113
+
114
+ def _run_git(repo: Path, args: Sequence[str], git_flags: Sequence[str],
115
+ env: Mapping[str, str] | None) -> subprocess.CompletedProcess:
116
+ return subprocess.run(["git", *git_flags, "-C", str(repo), *args], capture_output=True, text=True, check=False,
117
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
118
+ env=dict(env) if env is not None else None)
119
+
120
+
121
+ def _worktree_blocks(porcelain: str) -> list[list[str]]:
122
+ """`git worktree list --porcelain` split into one list of lines per worktree."""
123
+ blocks: list[list[str]] = []
124
+ for line in porcelain.splitlines():
125
+ if line.startswith("worktree "):
126
+ blocks.append([line])
127
+ elif line.strip() and blocks:
128
+ blocks[-1].append(line)
129
+ return blocks
130
+
131
+
132
+ def safe_remove_worktree(repo: Path, worktree: Path, *, force: bool = True, force_locked: bool = False,
133
+ git_flags: Sequence[str] = (), env: Mapping[str, str] | None = None) -> str | None:
134
+ """Remove the linked worktree `worktree` of `repo`: its links first, then `git worktree remove`.
135
+
136
+ None when git removed it, else the problem. Before any link is touched it refuses a path that is a link, the
137
+ main checkout, a path that is not a registered linked worktree of `repo`, a locked worktree unless
138
+ `force_locked`, and a dirty one unless `force`: git would keep those, and a kept worktree must keep its links.
139
+ `force` passes `--force` (a dirty worktree goes); `force_locked` passes it twice (a locked one goes too). A
140
+ relative `worktree` is taken from `repo`, not from the process cwd. `git_flags` go before `-C` (for example
141
+ `-c core.hooksPath=...`); `env` is the git environment."""
142
+ worktree = Path(worktree)
143
+ if not worktree.is_absolute():
144
+ worktree = Path(repo) / worktree
145
+ try:
146
+ if os.path.lexists(worktree) and is_link(os.lstat(worktree)):
147
+ return f"refusing to remove {worktree}: the worktree folder is a junction or symlink"
148
+ except OSError as exc:
149
+ return f"refusing to remove {worktree}: cannot inspect it: {exc}"
150
+ listing = _run_git(Path(repo), ("worktree", "list", "--porcelain"), git_flags, env)
151
+ if listing.returncode != 0:
152
+ return f"refusing to remove {worktree}: git worktree list failed: {(listing.stderr or '').strip()}"
153
+ blocks = _worktree_blocks(listing.stdout)
154
+ if not blocks:
155
+ return f"refusing to remove {worktree}: git listed no worktrees"
156
+ if _same(worktree, blocks[0][0][len("worktree "):]):
157
+ return f"refusing to remove {worktree}: it is the main checkout"
158
+ if _same(worktree, repo):
159
+ return f"refusing to remove {worktree}: it is the checkout git runs in"
160
+ block = next((b for b in blocks[1:] if _same(worktree, b[0][len("worktree "):])), None)
161
+ if block is None:
162
+ return f"refusing to remove {worktree}: not a registered worktree of {repo}"
163
+ if not force_locked and any(line == "locked" or line.startswith("locked ") for line in block):
164
+ return f"refusing to remove {worktree}: it is locked (git worktree unlock it first); its links were kept"
165
+ if not force and os.path.lexists(worktree):
166
+ status = _run_git(worktree, ("status", "--porcelain"), git_flags, env)
167
+ if status.returncode != 0:
168
+ return f"refusing to remove {worktree}: git status failed: {(status.stderr or '').strip()}"
169
+ if status.stdout.strip():
170
+ return f"refusing to remove {worktree}: it has uncommitted changes; its links were kept"
171
+ try:
172
+ removed = remove_links(worktree)
173
+ except OSError as exc:
174
+ return f"not removed: {worktree}: a link could not be removed: {exc}; no file was deleted"
175
+ flags = ["--force", "--force"] if force_locked else (["--force"] if force else [])
176
+ proc = _run_git(Path(repo), ("worktree", "remove", *flags, str(worktree)), git_flags, env)
177
+ if proc.returncode != 0:
178
+ detail = (proc.stderr or proc.stdout or "").strip() or str(worktree)
179
+ if removed:
180
+ detail += f" ({len(removed)} link(s) inside it were removed first)"
181
+ return f"git worktree remove failed: {detail}"
182
+ return None
@@ -448,6 +448,8 @@ def _repo_root() -> Path:
448
448
  out = subprocess.run(
449
449
  ["git", "rev-parse", "--show-toplevel"],
450
450
  capture_output=True, text=True, timeout=5,
451
+ # No console window when the parent has none (a hook or an editor task).
452
+ creationflags=0x08000000 if os.name == "nt" else 0,
451
453
  )
452
454
  if out.returncode == 0 and out.stdout.strip():
453
455
  return Path(out.stdout.strip())
@@ -41,6 +41,10 @@ FIX_HINTS: dict[str, str] = {
41
41
 
42
42
  # Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
43
43
  # these — the recorded schemas may be stale and want a re-probe.
44
+ # No child process opens a console window on Windows (this runs under hooks and status lines that
45
+ # have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
46
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
47
+
44
48
  PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
45
49
  AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_doc_coverage)
46
50
 
@@ -100,7 +104,7 @@ def _which(name: str) -> str | None:
100
104
 
101
105
  def _binary_version(path: str) -> str | None:
102
106
  try:
103
- out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20)
107
+ out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW)
104
108
  return (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else None
105
109
  except Exception:
106
110
  return None
@@ -461,7 +465,7 @@ def host_guard_findings(
461
465
  if sys.platform != "win32":
462
466
  return []
463
467
 
464
- run = run or subprocess.run
468
+ run = run or (lambda *a, **k: subprocess.run(*a, creationflags=_NO_WINDOW, **k))
465
469
  out: list[tuple[str, str]] = []
466
470
 
467
471
  try:
@@ -0,0 +1,49 @@
1
+ """Start subprocesses without a visible console window on Windows. No dependencies.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A process that has NO console (a Claude session started by VS Code, the `claude daemon`, a
6
+ background session) makes Windows open a NEW, VISIBLE console for every console program it starts
7
+ (git.exe, cmd, python, npx, ruff). Hooks and the status line run in exactly such sessions, so each
8
+ `subprocess.run(["git", ...])` flashed a window (owner report, 19 Oct).
9
+
10
+ `CREATE_NO_WINDOW` fixes it. The child gets a hidden console, and output captured through pipes
11
+ still works. When the parent already has a console the flag is harmless.
12
+
13
+ Never add `DETACHED_PROCESS`: Windows then ignores `CREATE_NO_WINDOW`, the child has no console,
14
+ and every program it starts opens its own visible window. `merge()` clears that bit.
15
+
16
+ Use
17
+ ---
18
+ from no_window import run # a drop-in for subprocess.run
19
+ subprocess.run(argv, **NO_WINDOW_KW) # or spread the keywords yourself
20
+
21
+ On POSIX `NO_WINDOW_KW` is empty and `merge()` returns the keywords unchanged.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import subprocess
27
+
28
+ CREATE_NO_WINDOW = 0x08000000
29
+ DETACHED_PROCESS = 0x00000008
30
+
31
+ NO_WINDOW_KW = {"creationflags": CREATE_NO_WINDOW} if os.name == "nt" else {}
32
+
33
+
34
+ def merge(kwargs: dict) -> dict:
35
+ """A copy of `kwargs` with CREATE_NO_WINDOW ORed into `creationflags` (Windows only)."""
36
+ out = dict(kwargs)
37
+ if os.name == "nt":
38
+ out["creationflags"] = (int(out.get("creationflags") or 0) & ~DETACHED_PROCESS) | CREATE_NO_WINDOW
39
+ return out
40
+
41
+
42
+ def run(*args, **kwargs):
43
+ """subprocess.run that never opens a console window on Windows."""
44
+ return subprocess.run(*args, **merge(kwargs))
45
+
46
+
47
+ def popen(*args, **kwargs):
48
+ """subprocess.Popen that never opens a console window on Windows."""
49
+ return subprocess.Popen(*args, **merge(kwargs))