@caddis/cli 0.4.55 → 0.4.56
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_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +378 -37
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +3 -0
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
- package/bundles/antigravity-plugin/session_end_agy.py +14 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +6 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -57,10 +57,10 @@ review child (the reviewer has full Bash) and the fix session are each guarded b
|
|
|
57
57
|
session; the runner writes `reviewed/<plan>.json` only between children, before the next child's snapshot.
|
|
58
58
|
|
|
59
59
|
Snapshot guard: one baseline across the whole run. The Milestones and Blockers tables (every cell of every row, rows
|
|
60
|
-
added, removed or moved)
|
|
61
|
-
the runner's own writes and after each session) is compared with the previous read: the
|
|
62
|
-
the exact edits the runner made and recorded for that read (a Status it set, a blocker
|
|
63
|
-
rollup's audit entry says it actually wrote). Anything else stops the run `FAILED roadmap changed` (exit 1). Every
|
|
60
|
+
added, removed or moved), the `Runner host` line and the `**Exit setup:**` line are frozen. Every origin read (each
|
|
61
|
+
`next`, and the reads after the runner's own writes and after each session) is compared with the previous read: the
|
|
62
|
+
only differences allowed are the exact edits the runner made and recorded for that read (a Status it set, a blocker
|
|
63
|
+
row it added, and what a rollup's audit entry says it actually wrote). Anything else stops the run `FAILED roadmap changed` (exit 1). Every
|
|
64
64
|
roadmap change goes through `caddis_roadmap.py` as a child process with `CADDIS_HEADLESS=1`; the runner never passes
|
|
65
65
|
`--by-owner`.
|
|
66
66
|
|
|
@@ -90,8 +90,9 @@ The runner never passes an owner gate, never raises a cap and never deletes a ST
|
|
|
90
90
|
|
|
91
91
|
Exit codes: 0 stopped at a status the session wrote (plan mode: OWNER-GATE, BLOCKED), or at a STOP file after a
|
|
92
92
|
session; 1 a FAILED stop, a cap, the agy spend cap, a usage limit, `BLOCKED stuck`, `BLOCKED nothing ready`,
|
|
93
|
-
`BLOCKED waiting for merge`, `BLOCKED review`, `BLOCKED review not run`,
|
|
94
|
-
|
|
93
|
+
`BLOCKED waiting for merge`, `BLOCKED review`, `BLOCKED review not run`, `BLOCKED rollup not run` (a rollup exit 3,
|
|
94
|
+
for example an exit check that cannot run in the merged checkout; not restartable by the recheck task), or the
|
|
95
|
+
snapshot guard; 3 never started (lock, STOP file, no governor, agy spend cap not configured, another host, a plan a roadmap runner holds); 4 bad input (no plan or roadmap, both, a bad slug, no prompt template,
|
|
95
96
|
bad engine).
|
|
96
97
|
|
|
97
98
|
Engines: `claude` is built and live-tested. `agy` and `codex` are command-line adapters with unit tests only (not
|
|
@@ -134,6 +135,7 @@ import caddis_redact # noqa: E402
|
|
|
134
135
|
import caddis_review # noqa: E402
|
|
135
136
|
import caddis_roadmap # noqa: E402
|
|
136
137
|
import caddis_spend # noqa: E402
|
|
138
|
+
import caddis_worktree_safety # noqa: E402
|
|
137
139
|
import caddis_workstreams # noqa: E402
|
|
138
140
|
|
|
139
141
|
ENGINES: dict[str, dict] = {
|
|
@@ -148,12 +150,29 @@ PLAN_PROMPT_NAME = "plan-resume-prompt.md"
|
|
|
148
150
|
# background, and a print-mode session can end before it finishes, so its work is lost; the session prompt already
|
|
149
151
|
# asks for foreground work only. Normal subagents stay available.
|
|
150
152
|
HEADLESS_ENV = {"CADDIS_HEADLESS": "1", "CLAUDE_CODE_FORK_SUBAGENT": "0"}
|
|
151
|
-
|
|
153
|
+
# Robustness #4: what a session never gets. The publish tokens (the same list as caddis_lanes.LANE_SECRET_VARS), the
|
|
154
|
+
# runner publish step's marker, and the guard kill switch (the guard denies push, merge and publish headless).
|
|
155
|
+
SESSION_SECRET_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN",
|
|
156
|
+
"CADDIS_ALLOW_PUBLISH", "CADDIS_GUARD_DISABLED")
|
|
157
|
+
SESSION_NO_PROMPT_ENV = {"GH_PROMPT_DISABLED": "1", "GIT_TERMINAL_PROMPT": "0"}
|
|
158
|
+
# Layer 3 for claude sessions. Not a boundary: Claude Code matches a rule against the raw command string, so
|
|
159
|
+
# `git "push"` or `bash -c "git push"` passes it (#65, 18 Oct). The boundary is layer 1 (session_env: no token) and
|
|
160
|
+
# layer 2 (the guard denies push, merge and publish when CADDIS_HEADLESS=1). With those, bypassPermissions is
|
|
161
|
+
# acceptable for implementers: they need a shell and the write tools, and on native Windows there is no sandbox.
|
|
162
|
+
SESSION_DENY_RULES = ("Bash(git push:*)", "Bash(gh pr merge:*)", "Bash(gh pr create:*)", "Bash(gh release:*)",
|
|
163
|
+
"Bash(gh api:*)", "Bash(npm publish:*)")
|
|
164
|
+
ROADMAP_WRITE_TIMEOUT_S = (caddis_roadmap.MERGE_CHAIN_TIMEOUT + caddis_roadmap.EXIT_SETUP_TIMEOUT
|
|
165
|
+
+ caddis_roadmap.EXIT_CHECK_TIMEOUT + 600) # a backstop: every call inside is bounded
|
|
152
166
|
RUNNING = "running" # the last-stop record while a runner holds the lock; a killed run leaves it (needs the owner)
|
|
153
167
|
GH_STOP_PREFIX = "BLOCKED gh cannot see" # a gh error at a restart: waiting.json stays, a later tick may resume
|
|
154
168
|
RESTARTABLE_STOP = "BLOCKED nothing ready" # restartable once a blocker cleared
|
|
155
169
|
WAITING_PREFIX = "BLOCKED waiting for merge" # restartable once its PR merged
|
|
156
|
-
|
|
170
|
+
# The blocker rollup writes: its stable prefix, then (since caddis-robustness Phase 7) `: ` and the output tail. The
|
|
171
|
+
# lookahead caps the WHOLE reason at caddis_roadmap.REASON_CELL_CHARS, the same cap blocker_reason applies, so the
|
|
172
|
+
# tail may hold REASON_CELL_CHARS - len(prefix) - len(": ") characters at most.
|
|
173
|
+
ROLLUP_BLOCK_RE = (rf"(?=[ -~]{{1,{caddis_roadmap.REASON_CELL_CHARS}}}\Z)" + caddis_roadmap.ROLLUP_PREFIX_RE.pattern
|
|
174
|
+
+ r"(: [ -~]+)?")
|
|
175
|
+
ROLLUP_NOT_RUN = "BLOCKED rollup not run" # rollup exit 3 (exit check, Exit setup, git or lock); the owner decides
|
|
157
176
|
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
158
177
|
_CLOSED = {"done", "not-needed"}
|
|
159
178
|
_LANE_RE = re.compile(r"^\s*\*\*Lane:\*\*\s*(.*?)\s*$")
|
|
@@ -184,6 +203,25 @@ def headless_env(base: dict | None = None) -> dict:
|
|
|
184
203
|
return env
|
|
185
204
|
|
|
186
205
|
|
|
206
|
+
def session_env(base: dict | None = None) -> dict:
|
|
207
|
+
"""The environment of a session (and of the review child, whose reviewer sessions inherit it): headless_env with
|
|
208
|
+
no publish credential (SESSION_SECRET_VARS) and no credential prompt. A session can be prompt-injected through a
|
|
209
|
+
plan or a diff; the runner pushes, opens the PR and merges by itself, with its own login (runner_env)."""
|
|
210
|
+
env = headless_env(base)
|
|
211
|
+
for name in SESSION_SECRET_VARS:
|
|
212
|
+
env.pop(name, None)
|
|
213
|
+
env.update(SESSION_NO_PROMPT_ENV)
|
|
214
|
+
return env
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def runner_env() -> dict:
|
|
218
|
+
"""The environment of the runner's own caddis_roadmap.py children (writes, merge chain, render): headless_env
|
|
219
|
+
plus the runner's gh login. Never given to a session."""
|
|
220
|
+
env = headless_env()
|
|
221
|
+
env.update(caddis_roadmap.AUTH_ENV)
|
|
222
|
+
return env
|
|
223
|
+
|
|
224
|
+
|
|
187
225
|
def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_min: float,
|
|
188
226
|
governor: Sequence[str], engine_cmd: Sequence[str] | None = None) -> list[str]:
|
|
189
227
|
"""The full command line for one session: the governor prefix, then the engine's print-mode call.
|
|
@@ -198,8 +236,9 @@ def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_m
|
|
|
198
236
|
# The mode is explicit: since Claude Code 2.1.302 a headless run with no mode starts in `default` (third-party
|
|
199
237
|
# providers) and no longer in auto mode, so a machine without `defaultMode` in its settings would stop at
|
|
200
238
|
# every permission prompt. The session runs in its own worktree under the governor, and the guard still runs.
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
# The deny rules are an extra only (SESSION_DENY_RULES); the token-free env and the guard are the boundary.
|
|
240
|
+
tail = [*exe, "-p", "--disallowedTools", *SESSION_DENY_RULES, "--permission-mode", "bypassPermissions",
|
|
241
|
+
"--model", model, "--output-format", "json", "--max-budget-usd", _usd(usd)]
|
|
203
242
|
elif engine == "agy": # probe 5: --model must come before --print
|
|
204
243
|
tail = [*exe, "--model", model, "--print", prompt, "--output-format", "json",
|
|
205
244
|
"--print-timeout", f"{max(1, int(timeout_min))}m"]
|
|
@@ -405,8 +444,8 @@ def roadmap_guard(before: "caddis_roadmap.Roadmap", after: "caddis_roadmap.Roadm
|
|
|
405
444
|
edits: Sequence[tuple] = ()) -> list[str]:
|
|
406
445
|
"""Every difference between two origin reads that the runner's own recorded `edits` do not explain.
|
|
407
446
|
|
|
408
|
-
The Milestones and Blockers tables
|
|
409
|
-
row order, and the multiset of blocker rows. `edits` are what the runner itself wrote since `before`:
|
|
447
|
+
The Milestones and Blockers tables, the `Runner host` line and the `**Exit setup:**` line are frozen: every cell
|
|
448
|
+
of every milestone row, the row order, and the multiset of blocker rows. `edits` are what the runner itself wrote since `before`:
|
|
410
449
|
`("status", mid, value)`, `("blocker", mid, type, reason)` (a row with no Check; nothing when the same
|
|
411
450
|
milestone, type and reason exist, as `caddis_roadmap.add_blocker` does), and `("blocker-like", mid, type,
|
|
412
451
|
regex)` (one added row whose reason matches, for the blocker a rollup writes)."""
|
|
@@ -426,6 +465,8 @@ def roadmap_guard(before: "caddis_roadmap.Roadmap", after: "caddis_roadmap.Roadm
|
|
|
426
465
|
changes: list[str] = []
|
|
427
466
|
if after.runner_host != before.runner_host:
|
|
428
467
|
changes.append("Runner host changed")
|
|
468
|
+
if after.exit_setup != before.exit_setup: # a command rollup runs unattended, like an Exit check
|
|
469
|
+
changes.append("Exit setup changed")
|
|
429
470
|
now = {ms.id: _milestone_cells(ms) for ms in after.milestones}
|
|
430
471
|
changes += [f"{mid} milestone removed" for mid in rows if mid not in now]
|
|
431
472
|
changes += [f"{mid} milestone added" for mid in now if mid not in rows]
|
|
@@ -655,11 +696,13 @@ GH_ACCOUNT = {"name": None} # the account the runner's gh calls use (GH_TOKEN s
|
|
|
655
696
|
|
|
656
697
|
|
|
657
698
|
def use_gh_account(account: str | None) -> str | None:
|
|
658
|
-
"""Make every gh call of this process (and of
|
|
659
|
-
`account`: GH_TOKEN from `gh auth token -u <account
|
|
660
|
-
|
|
699
|
+
"""Make every gh call of this process (through caddis_roadmap._quiet_env) and of its caddis_roadmap.py children
|
|
700
|
+
(runner_env) use `account`: GH_TOKEN from `gh auth token -u <account>`, held in caddis_roadmap.AUTH_ENV, never in
|
|
701
|
+
os.environ, so no session inherits it. None keeps the environment / active account. Returns the value set, so
|
|
702
|
+
it can be masked; GhError when gh cannot give it."""
|
|
661
703
|
GH_ACCOUNT["name"] = account or None
|
|
662
704
|
if not account:
|
|
705
|
+
caddis_roadmap.AUTH_ENV.pop("GH_TOKEN", None)
|
|
663
706
|
return None
|
|
664
707
|
try:
|
|
665
708
|
proc = subprocess.run(["gh", "auth", "token", "-u", account], capture_output=True, text=True,
|
|
@@ -671,7 +714,7 @@ def use_gh_account(account: str | None) -> str | None:
|
|
|
671
714
|
gh_auth = (proc.stdout or "").strip()
|
|
672
715
|
if proc.returncode != 0 or not gh_auth:
|
|
673
716
|
raise GhError(f"gh has no login for account {account}", caddis_exit.BLOCKED)
|
|
674
|
-
|
|
717
|
+
caddis_roadmap.AUTH_ENV["GH_TOKEN"] = gh_auth
|
|
675
718
|
return gh_auth
|
|
676
719
|
|
|
677
720
|
|
|
@@ -852,7 +895,7 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
|
|
|
852
895
|
_, hash_before = _read_relay(cwd, relay_slug)
|
|
853
896
|
record = {"kind": "session", "n": n, "start": _iso(ctx.now()), "relay_hash_before": hash_before}
|
|
854
897
|
outcome = _launch(cmd, cwd, remaining_s, prompt if ctx.engine in PROMPT_ON_STDIN else None,
|
|
855
|
-
env=
|
|
898
|
+
env=session_env())
|
|
856
899
|
finally: # any exception, KeyboardInterrupt too, still settles the reservation
|
|
857
900
|
if rid is not None:
|
|
858
901
|
_spend_settle(ctx.home, rid, cmd, outcome, ctx.now(), ctx.clock() - launched)
|
|
@@ -1039,7 +1082,7 @@ def _run_review(ctx: _Ctx, tally: _Tally, plan_file: Path, phase: int, out_dir:
|
|
|
1039
1082
|
kind, slug = ctx.target.kind, ctx.target.slug
|
|
1040
1083
|
state_before = state_snapshot(ctx.repo, kind, slug) # the reviewer has full Bash: it is guarded like a session
|
|
1041
1084
|
try:
|
|
1042
|
-
proc = subprocess.run(argv, cwd=cwd, env=
|
|
1085
|
+
proc = subprocess.run(argv, cwd=cwd, env=session_env(), stdin=subprocess.DEVNULL, capture_output=True,
|
|
1043
1086
|
text=True, encoding="utf-8", errors="replace",
|
|
1044
1087
|
timeout=max(1.0, min(REVIEW_TIMEOUT_S, left_s)))
|
|
1045
1088
|
code, output = proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
|
@@ -1198,6 +1241,7 @@ def run(*, repo: Path, plan: str | None = None, roadmap: str | None = None, caps
|
|
|
1198
1241
|
return caddis_exit.BLOCKED
|
|
1199
1242
|
if value:
|
|
1200
1243
|
ctx.masks.append(value)
|
|
1244
|
+
caddis_roadmap.AUTH_ENV["GH_TOKEN"] = value # the runner's own calls only; never a session's env
|
|
1201
1245
|
if roadmap is not None:
|
|
1202
1246
|
rm_cmd = list(roadmap_cmd or default_roadmap_cmd())
|
|
1203
1247
|
try:
|
|
@@ -1218,7 +1262,7 @@ def _render_page(ctx: "_Ctx", roadmap_cmd: Sequence[str]) -> None:
|
|
|
1218
1262
|
"""Re-render the local roadmap page (roadmap-page Phase 3). Best effort: a failure is one stderr line, never a stop."""
|
|
1219
1263
|
argv = [*roadmap_cmd, "render", "--roadmap", ctx.target.slug, "--root", str(ctx.repo)]
|
|
1220
1264
|
try:
|
|
1221
|
-
proc = subprocess.run(argv, cwd=ctx.repo, env=
|
|
1265
|
+
proc = subprocess.run(argv, cwd=ctx.repo, env=runner_env(), stdin=subprocess.DEVNULL, capture_output=True,
|
|
1222
1266
|
text=True, encoding="utf-8", errors="replace", timeout=PAGE_RENDER_TIMEOUT_S)
|
|
1223
1267
|
if proc.returncode != 0:
|
|
1224
1268
|
print(f"caddis_roadmap_run: page not rendered (exit {proc.returncode})", file=sys.stderr)
|
|
@@ -1360,7 +1404,7 @@ def _roadmap_write(ctx: _Ctx, roadmap_cmd: Sequence[str], args: list[str]) -> in
|
|
|
1360
1404
|
raise ValueError("the runner never makes an owner-only roadmap change")
|
|
1361
1405
|
argv = [*roadmap_cmd, *args, "--roadmap", ctx.target.slug, "--root", str(ctx.repo)]
|
|
1362
1406
|
try:
|
|
1363
|
-
proc = subprocess.run(argv, cwd=ctx.repo, env=
|
|
1407
|
+
proc = subprocess.run(argv, cwd=ctx.repo, env=runner_env(), stdin=subprocess.DEVNULL, capture_output=True,
|
|
1364
1408
|
text=True, encoding="utf-8", errors="replace", timeout=ROADMAP_WRITE_TIMEOUT_S)
|
|
1365
1409
|
except subprocess.TimeoutExpired:
|
|
1366
1410
|
print(f"caddis_roadmap_run: roadmap {args[0]} timed out", file=sys.stderr)
|
|
@@ -1377,8 +1421,9 @@ def _roadmap_write(ctx: _Ctx, roadmap_cmd: Sequence[str], args: list[str]) -> in
|
|
|
1377
1421
|
|
|
1378
1422
|
class _Baseline:
|
|
1379
1423
|
"""The snapshot guard's baseline, kept across iterations. Every origin read is compared with the previous read
|
|
1380
|
-
first: the Milestones and Blockers tables
|
|
1381
|
-
runner itself made and recorded (`expect`) since that read. Then the new read becomes the
|
|
1424
|
+
first: the Milestones and Blockers tables, the Runner host line and the Exit setup line are frozen, except for
|
|
1425
|
+
the exact edits the runner itself made and recorded (`expect`) since that read. Then the new read becomes the
|
|
1426
|
+
baseline."""
|
|
1382
1427
|
|
|
1383
1428
|
def __init__(self, roadmap: "caddis_roadmap.Roadmap"):
|
|
1384
1429
|
self.roadmap = roadmap
|
|
@@ -1539,7 +1584,7 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
|
|
|
1539
1584
|
return f"worktree kept (status not readable): {path}"
|
|
1540
1585
|
if work:
|
|
1541
1586
|
return f"worktree kept (not clean, {len(work)} change(s)): {path}"
|
|
1542
|
-
if
|
|
1587
|
+
if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
|
|
1543
1588
|
return f"worktree kept (remove failed): {path}"
|
|
1544
1589
|
_git_out(repo, "worktree", "prune")
|
|
1545
1590
|
_state_file(repo, slug, "worktree").unlink(missing_ok=True)
|
|
@@ -1740,6 +1785,26 @@ def _run_roadmap(ctx: _Ctx, template: Path, dry_run: bool, after_session: Callab
|
|
|
1740
1785
|
written = 0
|
|
1741
1786
|
seen = len(_audit_lines(repo, slug))
|
|
1742
1787
|
rc = _roadmap_write(ctx, roadmap_cmd, ["rollup", "--id", mid])
|
|
1788
|
+
if rc == caddis_exit.NOT_RUN: # a retry would fail the same way: the owner decides
|
|
1789
|
+
opened = []
|
|
1790
|
+
for line in _audit_lines(repo, slug)[seen:]:
|
|
1791
|
+
try:
|
|
1792
|
+
entry = json.loads(line)
|
|
1793
|
+
except ValueError:
|
|
1794
|
+
continue
|
|
1795
|
+
if isinstance(entry, dict) and entry.get("id") == mid and entry.get("pr") is not None:
|
|
1796
|
+
opened.append(f"#{entry['pr']}")
|
|
1797
|
+
if len(opened) > 1:
|
|
1798
|
+
written = (f"its docs PRs {', '.join(opened)} were opened and may still be open (the merge chain did "
|
|
1799
|
+
"not finish): check them")
|
|
1800
|
+
elif opened:
|
|
1801
|
+
written = (f"its docs PR {opened[0]} was opened and may still be open (the merge chain did not "
|
|
1802
|
+
"finish): check it")
|
|
1803
|
+
else:
|
|
1804
|
+
written = "nothing written"
|
|
1805
|
+
raise _Stop(f"{ROLLUP_NOT_RUN}: {mid} (its exit check or Exit setup could not run, or a git, lock or "
|
|
1806
|
+
f"merge step; {written}; output in {caddis_roadmap.local_dir(repo, slug)}); the owner decides",
|
|
1807
|
+
caddis_exit.BLOCKED)
|
|
1743
1808
|
if rc not in (caddis_exit.CLEAN, caddis_exit.BLOCKED):
|
|
1744
1809
|
raise _Stop(f"FAILED roadmap rollup {mid} exit {rc}", caddis_exit.BLOCKED)
|
|
1745
1810
|
for line in _audit_lines(repo, slug)[seen:]:
|
|
@@ -2289,7 +2354,7 @@ def _launch(cmd: list[str], cwd: Path, timeout_s: float, stdin_text: str | None
|
|
|
2289
2354
|
try:
|
|
2290
2355
|
proc = subprocess.run(cmd, cwd=cwd, input=stdin_text if stdin_text is not None else "", capture_output=True,
|
|
2291
2356
|
text=True, encoding="utf-8", errors="replace", timeout=timeout_s,
|
|
2292
|
-
env=env if env is not None else
|
|
2357
|
+
env=env if env is not None else session_env())
|
|
2293
2358
|
except subprocess.TimeoutExpired as exc:
|
|
2294
2359
|
out = exc.stdout.decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
2295
2360
|
err = exc.stderr.decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
@@ -42,6 +42,10 @@ import datetime
|
|
|
42
42
|
import traceback
|
|
43
43
|
from pathlib import Path
|
|
44
44
|
|
|
45
|
+
# No child process opens a console window on Windows. Inline (not scripts/no_window.py) because this
|
|
46
|
+
# file also runs as a standalone copy with no sibling modules.
|
|
47
|
+
_NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
|
|
48
|
+
|
|
45
49
|
_DEBUG_LOG = Path.home() / ".caddis" / "statusline_debug.log"
|
|
46
50
|
def _debug_log(msg: str):
|
|
47
51
|
if not os.environ.get("CADDIS_STATUSLINE_DEBUG"):
|
|
@@ -223,7 +227,7 @@ def git_facts(cwd: str) -> dict:
|
|
|
223
227
|
try:
|
|
224
228
|
proc = subprocess.run(
|
|
225
229
|
["git", "-C", cwd, "--no-optional-locks", "status", "--porcelain=v2", "--branch"],
|
|
226
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8,
|
|
230
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8, **_NO_WINDOW_KW,
|
|
227
231
|
)
|
|
228
232
|
text = proc.stdout.decode("utf-8", "replace")
|
|
229
233
|
except Exception:
|
|
@@ -307,8 +311,8 @@ def ram_facts():
|
|
|
307
311
|
|
|
308
312
|
if sys.platform == "darwin":
|
|
309
313
|
total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], stdout=subprocess.PIPE,
|
|
310
|
-
timeout=1.0).stdout.strip())
|
|
311
|
-
vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0)
|
|
314
|
+
timeout=1.0, **_NO_WINDOW_KW).stdout.strip())
|
|
315
|
+
vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0, **_NO_WINDOW_KW)
|
|
312
316
|
page = 4096
|
|
313
317
|
free_pages = 0
|
|
314
318
|
for line in vm.stdout.decode("utf-8", "replace").splitlines():
|
|
@@ -85,6 +85,10 @@ except ModuleNotFoundError as exc:
|
|
|
85
85
|
# caddis_todo has put its own dependencies on the path by now; this one ships beside it.
|
|
86
86
|
from caddis_file_lock import atomic_write # noqa: E402
|
|
87
87
|
|
|
88
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
89
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
90
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
91
|
+
|
|
88
92
|
TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
|
|
89
93
|
|
|
90
94
|
# Legacy prompts written before the frontmatter convention existed (2026-07-23). They were
|
|
@@ -398,7 +402,7 @@ def _run_git_mv(repo_root: Path, src: Path, dst: Path) -> bool:
|
|
|
398
402
|
try:
|
|
399
403
|
result = subprocess.run(
|
|
400
404
|
["git", "mv", "--", str(src), str(dst)],
|
|
401
|
-
cwd=str(repo_root), capture_output=True, text=True, timeout=30,
|
|
405
|
+
cwd=str(repo_root), capture_output=True, text=True, timeout=30, creationflags=_NO_WINDOW,
|
|
402
406
|
)
|
|
403
407
|
return result.returncode == 0
|
|
404
408
|
except Exception:
|
|
@@ -509,7 +513,7 @@ def parked_resolutions(root: Path, commit_range: str) -> list[Resolution]:
|
|
|
509
513
|
["git", "log", "--reverse", "--date=short", f"--abbrev={_SHA_ABBREV}",
|
|
510
514
|
"--format=%h%x1f%cd%x1f%(trailers:only,unfold)%x1e", commit_range, "--"],
|
|
511
515
|
cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
512
|
-
timeout=30,
|
|
516
|
+
timeout=30, creationflags=_NO_WINDOW,
|
|
513
517
|
)
|
|
514
518
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
515
519
|
raise ValueError(f"git log failed: {exc}") from exc
|
|
@@ -610,7 +614,7 @@ def changed_paths(root: Path, commit_range: str) -> set[str]:
|
|
|
610
614
|
["git", "diff", "--name-only", "--no-renames", "--relative", "-z",
|
|
611
615
|
f"{base}...{head}", "--"],
|
|
612
616
|
cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
613
|
-
timeout=30,
|
|
617
|
+
timeout=30, creationflags=_NO_WINDOW,
|
|
614
618
|
)
|
|
615
619
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
616
620
|
raise ValueError(f"cannot read commit range {commit_range}: {exc}") from exc
|
|
@@ -624,7 +628,7 @@ def _rev(root: Path, ref: str) -> str | None:
|
|
|
624
628
|
"""The commit `ref` names, or None when git cannot resolve it."""
|
|
625
629
|
try:
|
|
626
630
|
done = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
|
627
|
-
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
631
|
+
cwd=str(root), capture_output=True, text=True, timeout=30, creationflags=_NO_WINDOW)
|
|
628
632
|
except (OSError, subprocess.SubprocessError):
|
|
629
633
|
return None
|
|
630
634
|
return done.stdout.strip() or None if done.returncode == 0 else None
|
|
@@ -641,7 +645,7 @@ def dirty_paths(root: Path, paths: set[str]) -> list[str]:
|
|
|
641
645
|
["git", "--literal-pathspecs", "status", "--porcelain", "-z", "--no-renames",
|
|
642
646
|
"--untracked-files=all", "--", *names[at:at + 200]],
|
|
643
647
|
cwd=str(root), capture_output=True, text=True, encoding="utf-8",
|
|
644
|
-
errors="replace", timeout=60,
|
|
648
|
+
errors="replace", timeout=60, creationflags=_NO_WINDOW,
|
|
645
649
|
)
|
|
646
650
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
647
651
|
raise ValueError(f"cannot read git status: {exc}") from exc
|
|
@@ -929,7 +933,7 @@ class PruneReport:
|
|
|
929
933
|
def _git_run(root: Path, *args: str, stdin: bytes | None = None):
|
|
930
934
|
try:
|
|
931
935
|
return subprocess.run(["git", "-C", str(root), *args], input=stdin,
|
|
932
|
-
capture_output=True, timeout=60)
|
|
936
|
+
capture_output=True, timeout=60, creationflags=_NO_WINDOW)
|
|
933
937
|
except (OSError, subprocess.SubprocessError):
|
|
934
938
|
return None
|
|
935
939
|
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import re
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
@@ -26,6 +27,10 @@ except ModuleNotFoundError as exc:
|
|
|
26
27
|
from caddis_workstreams import _artifact, _name
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
31
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
32
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
33
|
+
|
|
29
34
|
_ITEM = re.compile(r'^- \[([ x])\] (.+?) <!-- caddis: (\{.*\}) -->$')
|
|
30
35
|
_PHASE = re.compile(r'^\|\s*(\d+)\s*\|', re.M)
|
|
31
36
|
_WORD = re.compile(r'[a-z0-9]{3,}')
|
|
@@ -240,7 +245,8 @@ def _plans_changed_on_branch(root: Path, plans: list[Path]) -> list[Path]:
|
|
|
240
245
|
def git(*args: str) -> str | None:
|
|
241
246
|
try:
|
|
242
247
|
done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
|
|
243
|
-
encoding="utf-8", errors="replace", timeout=5, check=False
|
|
248
|
+
encoding="utf-8", errors="replace", timeout=5, check=False,
|
|
249
|
+
creationflags=_NO_WINDOW)
|
|
244
250
|
except (OSError, subprocess.SubprocessError):
|
|
245
251
|
return None
|
|
246
252
|
return done.stdout.strip() if done.returncode == 0 else None
|
|
@@ -28,6 +28,14 @@ except ModuleNotFoundError as exc:
|
|
|
28
28
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
29
29
|
import caddis_exit
|
|
30
30
|
|
|
31
|
+
try:
|
|
32
|
+
import caddis_worktree_safety
|
|
33
|
+
except ModuleNotFoundError as exc:
|
|
34
|
+
if exc.name != "caddis_worktree_safety":
|
|
35
|
+
raise
|
|
36
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
37
|
+
import caddis_worktree_safety
|
|
38
|
+
|
|
31
39
|
|
|
32
40
|
def _is_symlink_or_reparse(p: Path | str) -> bool:
|
|
33
41
|
"""Return True if path is a symlink or Windows reparse point."""
|
|
@@ -262,11 +270,10 @@ def close_worktree(
|
|
|
262
270
|
print(f"would remove {worktree}, branch {branch_name} (local and origin)")
|
|
263
271
|
return caddis_exit.CLEAN
|
|
264
272
|
|
|
265
|
-
# 10. Remove
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
print(f"failed command: {' '.join(cmd_wt_rm)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
|
|
273
|
+
# 10. Remove. Step 8 checks only the top level; the helper unlinks every deeper link before git removes the tree.
|
|
274
|
+
problem = caddis_worktree_safety.safe_remove_worktree(repo, worktree)
|
|
275
|
+
if problem:
|
|
276
|
+
print(f"failed command: git -C {repo} worktree remove --force {worktree}\n{problem}", file=sys.stderr)
|
|
270
277
|
return caddis_exit.ERROR
|
|
271
278
|
|
|
272
279
|
cmd_br_d = ["git", "-C", str(repo), "branch", "-D", branch_name]
|
|
@@ -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:
|