@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -7
- package/bundles/antigravity-plugin/scripts/caddis_review.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +9 -5
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +120 -1
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +3 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +3 -2
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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,
|
|
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", () => {
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED