@caddis/cli 0.4.68 → 0.4.69

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.154",
3
+ "version": "1.3.155",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -56,9 +56,6 @@ except ModuleNotFoundError as exc:
56
56
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
57
57
  import caddis_frontmatter
58
58
 
59
- if hasattr(sys.stdout, "reconfigure"):
60
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
61
-
62
59
  ART = ".caddis"
63
60
 
64
61
  # Artefact kinds worth enumerating, and what a reader needs from each. Order matters: it is the
@@ -277,9 +274,10 @@ def stale_artifacts(root: Path) -> list[dict]:
277
274
  return out
278
275
 
279
276
 
280
- def stale_plans(root: Path, days: int = 14) -> list[dict]:
281
- """Top-level plans in `status: doing` or `status: current` whose last git commit is older
282
- than `days` days."""
277
+ def stale_plans(root: Path, days: int = 14,
278
+ statuses: tuple[str, ...] = ("doing", "current")) -> list[dict]:
279
+ """Top-level plans whose `status:` is one of `statuses` (default `doing` or `current`) and whose
280
+ last git commit is older than `days` days. `caddis_tidy.py --check` calls it with `current` only."""
283
281
  d = root / ART / "plans"
284
282
  if not d.is_dir() or not (root / ".git").exists():
285
283
  return []
@@ -291,7 +289,7 @@ def stale_plans(root: Path, days: int = 14) -> list[dict]:
291
289
  continue
292
290
  fm = frontmatter(f)
293
291
  status = (fm.get("status") or "").strip()
294
- if status.lower() not in ("doing", "current"):
292
+ if status.lower() not in statuses:
295
293
  continue
296
294
  rel = f"{ART}/plans/{f.name}"
297
295
  ts = _run(["git", "log", "-1", "--format=%ct", "--", rel], root)
@@ -464,6 +462,10 @@ def render_md(inv: Inventory) -> str:
464
462
 
465
463
 
466
464
  def main(argv: list[str] | None = None) -> int:
465
+ # Only when run as a command: importing this module (caddis_tidy and caddis_gate do) must not
466
+ # change the importer's stdout encoding.
467
+ if hasattr(sys.stdout, "reconfigure"):
468
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
467
469
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
468
470
  ap.add_argument("--repo-root", default=".")
469
471
  ap.add_argument("--format", choices=("md", "json"), default="md")
@@ -999,7 +999,12 @@ def _prune_stale_review_worktrees(root: Path) -> None:
999
999
 
1000
1000
  def _add_worktree(root: Path, head: str, phase: int | str, run: int) -> Path:
1001
1001
  path = Path(tempfile.gettempdir()) / f"caddis-review-{phase}-{run}-{uuid.uuid4().hex[:8]}"
1002
- _git_out(root, "worktree", "add", "--detach", str(path), head)
1002
+ try:
1003
+ _git_out(root, "worktree", "add", "--detach", str(path), head)
1004
+ except BaseException:
1005
+ # A failed `worktree add` can leave the half-made folder: the caller has no path to remove (tidy v2 Phase 5).
1006
+ caddis_worktree_safety.remove_tree(path)
1007
+ raise
1003
1008
  return path
1004
1009
 
1005
1010
 
@@ -1081,8 +1081,10 @@ def merged_checkout(root: Path, branch: str):
1081
1081
  _git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
1082
1082
  yield worktree
1083
1083
  finally:
1084
- caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1085
- caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1084
+ try:
1085
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1086
+ finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
1087
+ caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1086
1088
 
1087
1089
 
1088
1090
  def _kill_tree(proc: subprocess.Popen) -> None:
@@ -1849,9 +1851,11 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1849
1851
  else:
1850
1852
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1851
1853
  finally:
1852
- caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1853
- _git(root, "branch", "-D", branch, check=False)
1854
- caddis_worktree_safety.remove_tree(tmp)
1854
+ try:
1855
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1856
+ _git(root, "branch", "-D", branch, check=False)
1857
+ finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
1858
+ caddis_worktree_safety.remove_tree(tmp)
1855
1859
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
1856
1860
  "pr": pr, "merge_exit": merge_exit if merge_exit is not None
1857
1861
  else (rc if pr is not None else None)})
@@ -38,6 +38,10 @@ DESIGN (locked, see the plan doc — do not re-litigate here)
38
38
  already in a repo never block an unrelated PR. It first refuses when any of those files has an
39
39
  uncommitted change (the check would read a file that is not what the PR ships), and a range git
40
40
  cannot read prints only that error, never an unfiltered list.
41
+ - `--check` also reports three things it never fixes (tidy v2 Phase 4), each with a one-line fix: untracked
42
+ crash/scratch files at the repo root (`*.stackdump`, `run*.ps1`, `nul`, ...); a `.caddis/` file whose
43
+ `type:` belongs to another folder; a plan `status: current` with no commit for 14 days. The first is
44
+ repo-wide and so never appears under `--commit-range`; the other two follow the range like any file.
41
45
  - `--only <plan> [<plan> ...]` limits a run to those top-level plans: nothing else is moved or
42
46
  stamped. `/implement` closes the plan it finished with it, so another session's finished work
43
47
  is never swept into this PR.
@@ -60,6 +64,7 @@ Usage:
60
64
  from __future__ import annotations
61
65
 
62
66
  import argparse
67
+ import fnmatch
63
68
  import os
64
69
  import re
65
70
  import stat
@@ -735,6 +740,116 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
735
740
  "into its own file and move the resolved history to done/.")
736
741
 
737
742
 
743
+ # ── wider --check (tidy v2 Phase 4): report only, nothing here moves or deletes a file ──────────
744
+ # Names that crash tools and one-off scripts leave at a repo root. Each one is a file nobody chose to
745
+ # keep, so an untracked, un-ignored match is a stray. Matched case-insensitively (Windows).
746
+ _STRAY_PATTERNS = (
747
+ ("*.stackdump", "a Git Bash/Cygwin crash dump"),
748
+ ("*.tmp", "an editor or tool temp file"),
749
+ ("*.bak", "a hand-made or tool backup copy"),
750
+ ("*.orig", "a merge or patch tool's pre-change copy"),
751
+ ("*.rej", "a rejected patch hunk"),
752
+ ("run*.ps1", "a one-off launcher script written during a session"),
753
+ ("tmp*.py", "a throwaway probe script"),
754
+ ("scratch*", "a scratch file or folder note"),
755
+ ("nul", "a Windows device name that a bad redirect (`> nul` run from a bash shell) wrote as a real file"),
756
+ )
757
+ STRAY_FIX = "delete it, or add it to .gitignore if it is meant to stay"
758
+
759
+ # Folder-defining `type:` values -> the `.caddis/` folder that holds them (None: no folder, the file is
760
+ # a root-level one such as relay.md). Only these can be misfiled. Generic types (note, reference, design,
761
+ # analysis, handoff, ...) are legitimately spread over plans/, kb/ and reviews/ in real repos, so they
762
+ # are never flagged. parking-lot/ is left to `_check_parking_lot_item`, which already enforces its type.
763
+ _TYPE_HOME = {
764
+ "plan": "plans", "prd": "prd", "prompt": "prompts", "review": "reviews", "rca": "rca",
765
+ "adr": "decisions", "decision": "decisions", "parking-lot": "parking-lot",
766
+ "relay": None, "session-state": None,
767
+ }
768
+ # The types each known folder accepts of those above. `relay/` holds relays; kb/ accepts none of them.
769
+ _FOLDER_TYPES = {
770
+ "plans": {"plan"}, "prd": {"prd"}, "prompts": {"prompt"}, "reviews": {"review"}, "rca": {"rca"},
771
+ "decisions": {"adr", "decision"}, "relay": {"relay"}, "kb": set(),
772
+ }
773
+ STALE_PLAN_DAYS = 14
774
+
775
+
776
+ def is_stray_name(name: str) -> bool:
777
+ """True when a root-level file name matches a crash/scratch pattern."""
778
+ low = name.lower()
779
+ return any(fnmatch.fnmatchcase(low, pat) for pat, _ in _STRAY_PATTERNS)
780
+
781
+
782
+ def _check_root_strays(repo_root: Path, report: Report) -> None:
783
+ """Untracked, un-ignored files directly at the repo root whose name looks like crash/scratch debris.
784
+ Repo-wide: a stray is local, so a `--commit-range` filter drops it (no commit can contain it).
785
+ Untracked files in subfolders are out of scope (too noisy). `--directory` collapses an untracked
786
+ folder to one `name/` entry, which has a slash and is skipped."""
787
+ if not _is_git_repo(repo_root):
788
+ return
789
+ done = _git_run(repo_root, "ls-files", "--others", "--exclude-standard", "--directory",
790
+ "--no-empty-directory", "-z")
791
+ if done is None or done.returncode != 0:
792
+ return
793
+ for name in sorted(done.stdout.decode("utf-8", "replace").split("\0")):
794
+ if name and "/" not in name and is_stray_name(name):
795
+ report._flag(repo_root / name, f"untracked stray file at the repo root; fix: {STRAY_FIX}")
796
+
797
+
798
+ def _doc_type(path: Path) -> str:
799
+ try:
800
+ text = path.read_text(encoding="utf-8", errors="ignore")
801
+ except OSError:
802
+ return ""
803
+ return str(_parse_frontmatter(text).get("type") or "").split("#")[0].strip().lower()
804
+
805
+
806
+ def _check_wrong_folder(repo_root: Path, report: Report) -> None:
807
+ """A `.caddis/` file whose `type:` names another folder's kind. Looks at the files directly in
808
+ `.caddis/` and in each known folder; `done/` and other subfolders are history and are skipped, and
809
+ so is a file with no frontmatter (most folders never require it). Under `--commit-range` it
810
+ applies to the files the range changed, like every other per-file check."""
811
+ caddis = repo_root / ".caddis"
812
+ if not caddis.is_dir():
813
+ return
814
+ for folder in ("", *_FOLDER_TYPES):
815
+ d = caddis / folder if folder else caddis
816
+ if not d.is_dir():
817
+ continue
818
+ for f in sorted(d.glob("*.md")):
819
+ kind = _doc_type(f) if f.name.lower() != "readme.md" and f.is_file() else ""
820
+ if kind not in _TYPE_HOME or kind in _FOLDER_TYPES.get(folder, ()):
821
+ continue
822
+ home = _TYPE_HOME[kind]
823
+ if not folder and home is None:
824
+ continue # relay.md and session-state*.md live at the `.caddis/` root
825
+ where = f".caddis/{folder}" if folder else ".caddis"
826
+ fix = f"git mv {f.relative_to(repo_root).as_posix()} .caddis/{home}/" if home else "move it"
827
+ report._flag(f, f"type is `{kind}`, which does not belong in {where}/; fix: {fix}")
828
+
829
+
830
+ def _check_stale_current_plans(repo_root: Path, report: Report) -> None:
831
+ """Plans `status: current` with no commit in 14 days. Reuses caddis_inventory.stale_plans, which
832
+ ships beside this file in every runtime. A plan a `--commit-range` changed has a fresh commit,
833
+ so a range check drops these naturally."""
834
+ try:
835
+ import caddis_inventory
836
+ except ModuleNotFoundError as exc:
837
+ if exc.name != "caddis_inventory":
838
+ raise
839
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
840
+ import caddis_inventory
841
+ for entry in caddis_inventory.stale_plans(repo_root, days=STALE_PLAN_DAYS, statuses=("current",)):
842
+ report._flag(repo_root / entry["file"],
843
+ f"status current, but no commit for {entry['days']} days; fix: set status: "
844
+ "done/superseded/draft in its frontmatter, or commit progress to it")
845
+
846
+
847
+ def _check_wider(repo_root: Path, report: Report) -> None:
848
+ _check_root_strays(repo_root, report)
849
+ _check_wrong_folder(repo_root, report)
850
+ _check_stale_current_plans(repo_root, report)
851
+
852
+
738
853
  def tidy(repo_root: Path, apply: bool = False, check: bool = False,
739
854
  kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None,
740
855
  only: list[str] | None = None) -> Report:
@@ -829,6 +944,8 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
829
944
  report.stale_suspects.append(item.path)
830
945
  if commit_range is not None and PARKING_LOT_DIR in kinds:
831
946
  _plan_parked(repo_root, commit_range, report, moves, rewrites)
947
+ if check:
948
+ _check_wider(repo_root, report)
832
949
  if check and report.in_range is not None:
833
950
  _limit_check_to(repo_root, report, report.in_range)
834
951
  if only_set is not None:
@@ -1139,7 +1256,9 @@ def main(argv: list[str] | None = None) -> int:
1139
1256
  ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
1140
1257
  ap.add_argument("--check", action="store_true",
1141
1258
  help="conformance check - exit 1 if any top-level artifact lacks a known "
1142
- "status, or a plan's Tracker is all done while its status is active")
1259
+ "status, a plan's Tracker is all done while its status is active, a stray "
1260
+ "crash/scratch file sits at the repo root, a .caddis file is in the wrong "
1261
+ "folder, or a `current` plan has no commit for 14 days (report only)")
1143
1262
  ap.add_argument("--commit-range", default=None,
1144
1263
  help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
1145
1264
  "commits only, e.g. origin/main..HEAD (default: read no trailers); "
@@ -1,7 +1,7 @@
1
- import { test } from "node:test";
1
+ import { after, test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -25,6 +25,7 @@ const good = {
25
25
  links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
26
26
  };
27
27
  const dir = mkdtempSync(join(tmpdir(), "flow-studio-"));
28
+ after(() => rmSync(dir, { recursive: true, force: true })); // a test run leaves nothing in the temp folder (tidy v2 Phase 5)
28
29
  const write = (name, value) => { const p = join(dir, name); writeFileSync(p, typeof value === "string" ? value : JSON.stringify(value)); return p; };
29
30
 
30
31
  test("validate: a good model exits 0 and prints OK", () => {
@@ -1,7 +1,7 @@
1
- import { test } from "node:test";
1
+ import { after, test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -25,6 +25,7 @@ const good = {
25
25
  links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
26
26
  };
27
27
  const dir = mkdtempSync(join(tmpdir(), "flow-studio-"));
28
+ after(() => rmSync(dir, { recursive: true, force: true })); // a test run leaves nothing in the temp folder (tidy v2 Phase 5)
28
29
  const write = (name, value) => { const p = join(dir, name); writeFileSync(p, typeof value === "string" ? value : JSON.stringify(value)); return p; };
29
30
 
30
31
  test("validate: a good model exits 0 and prints OK", () => {
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.154",
2
+ "poolVersion": "1.3.155",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.154",
4
+ "antigravity-plugin": "1.3.155",
5
5
  "antigravity-plugin-extras": "1.3.46",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.68",
3
+ "version": "0.4.69",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",