@caddis/cli 0.4.68 → 0.4.70
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_budget.py +0 -38
- 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_roadmap_run.py +5 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +10 -451
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +126 -8
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +35 -352
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +3 -2
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +25 -39
- package/bundles/antigravity-plugin/warm_start_agy.py +3 -40
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +1 -1
- 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
|
@@ -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
|
|
@@ -84,7 +89,7 @@ except ModuleNotFoundError as exc:
|
|
|
84
89
|
raise
|
|
85
90
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
86
91
|
import caddis_todo
|
|
87
|
-
# caddis_todo has put its own dependencies on the path by now; this one ships beside it.
|
|
92
|
+
# caddis_todo (through caddis_workstreams) has put its own dependencies on the path by now; this one ships beside it.
|
|
88
93
|
from caddis_file_lock import atomic_write # noqa: E402
|
|
89
94
|
|
|
90
95
|
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
@@ -155,9 +160,8 @@ def _parse_frontmatter(text: str) -> dict:
|
|
|
155
160
|
def tracker_complete(text: str) -> bool:
|
|
156
161
|
"""True only when the `## Tracker` heading line has phase rows, all unique, all done.
|
|
157
162
|
|
|
158
|
-
The table is read by caddis_todo.tracker_table,
|
|
159
|
-
|
|
160
|
-
row it cannot read, while a row this cannot read BLOCKS completion. Wrongly moving a live plan
|
|
163
|
+
The table is read by caddis_todo.tracker_table, and a status cell is judged by its `_is_done`.
|
|
164
|
+
Strict on purpose: a row this cannot read BLOCKS completion. Wrongly moving a live plan
|
|
161
165
|
hides it; wrongly keeping a finished one costs a look.
|
|
162
166
|
"""
|
|
163
167
|
table = caddis_todo.tracker_table(text)
|
|
@@ -471,9 +475,9 @@ class Resolution:
|
|
|
471
475
|
def _parked_target(root: Path, name: str) -> tuple[Path | None, str | None]:
|
|
472
476
|
"""Where a trailer value points: (open item, None), (None, None) when it is already in done/,
|
|
473
477
|
or (None, reason) when it is refused or missing. Only a top-level item of this repo's
|
|
474
|
-
`.caddis/parking-lot/` is ever a target, named bare, as `parking-lot/<file>` (what
|
|
475
|
-
`caddis_todo.py done`
|
|
476
|
-
caddis_todo._resolves_path
|
|
478
|
+
`.caddis/parking-lot/` is ever a target, named bare, as `parking-lot/<file>` (what the
|
|
479
|
+
retired `caddis_todo.py done` printed) or by its repo-relative path. The last word is
|
|
480
|
+
caddis_todo._resolves_path."""
|
|
477
481
|
lot = root / ".caddis" / PARKING_LOT_DIR
|
|
478
482
|
posix = name.replace("\\", "/")
|
|
479
483
|
if not posix:
|
|
@@ -735,6 +739,116 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
|
|
|
735
739
|
"into its own file and move the resolved history to done/.")
|
|
736
740
|
|
|
737
741
|
|
|
742
|
+
# ── wider --check (tidy v2 Phase 4): report only, nothing here moves or deletes a file ──────────
|
|
743
|
+
# Names that crash tools and one-off scripts leave at a repo root. Each one is a file nobody chose to
|
|
744
|
+
# keep, so an untracked, un-ignored match is a stray. Matched case-insensitively (Windows).
|
|
745
|
+
_STRAY_PATTERNS = (
|
|
746
|
+
("*.stackdump", "a Git Bash/Cygwin crash dump"),
|
|
747
|
+
("*.tmp", "an editor or tool temp file"),
|
|
748
|
+
("*.bak", "a hand-made or tool backup copy"),
|
|
749
|
+
("*.orig", "a merge or patch tool's pre-change copy"),
|
|
750
|
+
("*.rej", "a rejected patch hunk"),
|
|
751
|
+
("run*.ps1", "a one-off launcher script written during a session"),
|
|
752
|
+
("tmp*.py", "a throwaway probe script"),
|
|
753
|
+
("scratch*", "a scratch file or folder note"),
|
|
754
|
+
("nul", "a Windows device name that a bad redirect (`> nul` run from a bash shell) wrote as a real file"),
|
|
755
|
+
)
|
|
756
|
+
STRAY_FIX = "delete it, or add it to .gitignore if it is meant to stay"
|
|
757
|
+
|
|
758
|
+
# Folder-defining `type:` values -> the `.caddis/` folder that holds them (None: no folder, the file is
|
|
759
|
+
# a root-level one such as relay.md). Only these can be misfiled. Generic types (note, reference, design,
|
|
760
|
+
# analysis, handoff, ...) are legitimately spread over plans/, kb/ and reviews/ in real repos, so they
|
|
761
|
+
# are never flagged. parking-lot/ is left to `_check_parking_lot_item`, which already enforces its type.
|
|
762
|
+
_TYPE_HOME = {
|
|
763
|
+
"plan": "plans", "prd": "prd", "prompt": "prompts", "review": "reviews", "rca": "rca",
|
|
764
|
+
"adr": "decisions", "decision": "decisions", "parking-lot": "parking-lot",
|
|
765
|
+
"relay": None, "session-state": None,
|
|
766
|
+
}
|
|
767
|
+
# The types each known folder accepts of those above. `relay/` holds relays; kb/ accepts none of them.
|
|
768
|
+
_FOLDER_TYPES = {
|
|
769
|
+
"plans": {"plan"}, "prd": {"prd"}, "prompts": {"prompt"}, "reviews": {"review"}, "rca": {"rca"},
|
|
770
|
+
"decisions": {"adr", "decision"}, "relay": {"relay"}, "kb": set(),
|
|
771
|
+
}
|
|
772
|
+
STALE_PLAN_DAYS = 14
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def is_stray_name(name: str) -> bool:
|
|
776
|
+
"""True when a root-level file name matches a crash/scratch pattern."""
|
|
777
|
+
low = name.lower()
|
|
778
|
+
return any(fnmatch.fnmatchcase(low, pat) for pat, _ in _STRAY_PATTERNS)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _check_root_strays(repo_root: Path, report: Report) -> None:
|
|
782
|
+
"""Untracked, un-ignored files directly at the repo root whose name looks like crash/scratch debris.
|
|
783
|
+
Repo-wide: a stray is local, so a `--commit-range` filter drops it (no commit can contain it).
|
|
784
|
+
Untracked files in subfolders are out of scope (too noisy). `--directory` collapses an untracked
|
|
785
|
+
folder to one `name/` entry, which has a slash and is skipped."""
|
|
786
|
+
if not _is_git_repo(repo_root):
|
|
787
|
+
return
|
|
788
|
+
done = _git_run(repo_root, "ls-files", "--others", "--exclude-standard", "--directory",
|
|
789
|
+
"--no-empty-directory", "-z")
|
|
790
|
+
if done is None or done.returncode != 0:
|
|
791
|
+
return
|
|
792
|
+
for name in sorted(done.stdout.decode("utf-8", "replace").split("\0")):
|
|
793
|
+
if name and "/" not in name and is_stray_name(name):
|
|
794
|
+
report._flag(repo_root / name, f"untracked stray file at the repo root; fix: {STRAY_FIX}")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _doc_type(path: Path) -> str:
|
|
798
|
+
try:
|
|
799
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
800
|
+
except OSError:
|
|
801
|
+
return ""
|
|
802
|
+
return str(_parse_frontmatter(text).get("type") or "").split("#")[0].strip().lower()
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _check_wrong_folder(repo_root: Path, report: Report) -> None:
|
|
806
|
+
"""A `.caddis/` file whose `type:` names another folder's kind. Looks at the files directly in
|
|
807
|
+
`.caddis/` and in each known folder; `done/` and other subfolders are history and are skipped, and
|
|
808
|
+
so is a file with no frontmatter (most folders never require it). Under `--commit-range` it
|
|
809
|
+
applies to the files the range changed, like every other per-file check."""
|
|
810
|
+
caddis = repo_root / ".caddis"
|
|
811
|
+
if not caddis.is_dir():
|
|
812
|
+
return
|
|
813
|
+
for folder in ("", *_FOLDER_TYPES):
|
|
814
|
+
d = caddis / folder if folder else caddis
|
|
815
|
+
if not d.is_dir():
|
|
816
|
+
continue
|
|
817
|
+
for f in sorted(d.glob("*.md")):
|
|
818
|
+
kind = _doc_type(f) if f.name.lower() != "readme.md" and f.is_file() else ""
|
|
819
|
+
if kind not in _TYPE_HOME or kind in _FOLDER_TYPES.get(folder, ()):
|
|
820
|
+
continue
|
|
821
|
+
home = _TYPE_HOME[kind]
|
|
822
|
+
if not folder and home is None:
|
|
823
|
+
continue # relay.md and session-state*.md live at the `.caddis/` root
|
|
824
|
+
where = f".caddis/{folder}" if folder else ".caddis"
|
|
825
|
+
fix = f"git mv {f.relative_to(repo_root).as_posix()} .caddis/{home}/" if home else "move it"
|
|
826
|
+
report._flag(f, f"type is `{kind}`, which does not belong in {where}/; fix: {fix}")
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _check_stale_current_plans(repo_root: Path, report: Report) -> None:
|
|
830
|
+
"""Plans `status: current` with no commit in 14 days. Reuses caddis_inventory.stale_plans, which
|
|
831
|
+
ships beside this file in every runtime. A plan a `--commit-range` changed has a fresh commit,
|
|
832
|
+
so a range check drops these naturally."""
|
|
833
|
+
try:
|
|
834
|
+
import caddis_inventory
|
|
835
|
+
except ModuleNotFoundError as exc:
|
|
836
|
+
if exc.name != "caddis_inventory":
|
|
837
|
+
raise
|
|
838
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
839
|
+
import caddis_inventory
|
|
840
|
+
for entry in caddis_inventory.stale_plans(repo_root, days=STALE_PLAN_DAYS, statuses=("current",)):
|
|
841
|
+
report._flag(repo_root / entry["file"],
|
|
842
|
+
f"status current, but no commit for {entry['days']} days; fix: set status: "
|
|
843
|
+
"done/superseded/draft in its frontmatter, or commit progress to it")
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _check_wider(repo_root: Path, report: Report) -> None:
|
|
847
|
+
_check_root_strays(repo_root, report)
|
|
848
|
+
_check_wrong_folder(repo_root, report)
|
|
849
|
+
_check_stale_current_plans(repo_root, report)
|
|
850
|
+
|
|
851
|
+
|
|
738
852
|
def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
739
853
|
kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None,
|
|
740
854
|
only: list[str] | None = None) -> Report:
|
|
@@ -829,6 +943,8 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
829
943
|
report.stale_suspects.append(item.path)
|
|
830
944
|
if commit_range is not None and PARKING_LOT_DIR in kinds:
|
|
831
945
|
_plan_parked(repo_root, commit_range, report, moves, rewrites)
|
|
946
|
+
if check:
|
|
947
|
+
_check_wider(repo_root, report)
|
|
832
948
|
if check and report.in_range is not None:
|
|
833
949
|
_limit_check_to(repo_root, report, report.in_range)
|
|
834
950
|
if only_set is not None:
|
|
@@ -1139,7 +1255,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1139
1255
|
ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
|
|
1140
1256
|
ap.add_argument("--check", action="store_true",
|
|
1141
1257
|
help="conformance check - exit 1 if any top-level artifact lacks a known "
|
|
1142
|
-
"status,
|
|
1258
|
+
"status, a plan's Tracker is all done while its status is active, a stray "
|
|
1259
|
+
"crash/scratch file sits at the repo root, a .caddis file is in the wrong "
|
|
1260
|
+
"folder, or a `current` plan has no commit for 14 days (report only)")
|
|
1143
1261
|
ap.add_argument("--commit-range", default=None,
|
|
1144
1262
|
help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
|
|
1145
1263
|
"commits only, e.g. origin/main..HEAD (default: read no trailers); "
|
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The active plan and the plan Tracker reader, for commands, tidy and the gates.
|
|
2
|
+
|
|
3
|
+
3-store model (owner decision, 21 Oct 2026): the session task list (`.caddis/relay/<name>.tasks.md`), the
|
|
4
|
+
parking lot and the roadmap with the plan Tracker. The `.caddis/todo-list/` store and the open-work view
|
|
5
|
+
are retired, with the `add`, `done`, `list` and `open-work` sub-commands. `reconcile` stays one more
|
|
6
|
+
release as a no-op, because an older /handoff still calls it from its Step 6.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
5
11
|
import argparse
|
|
6
|
-
import json
|
|
7
12
|
import os
|
|
8
13
|
import re
|
|
9
14
|
import subprocess
|
|
10
15
|
import sys
|
|
11
|
-
import uuid
|
|
12
16
|
from pathlib import Path
|
|
13
17
|
|
|
14
18
|
try:
|
|
15
19
|
import caddis_exit
|
|
16
|
-
from
|
|
17
|
-
from caddis_frontmatter import parse, split_document
|
|
20
|
+
from caddis_frontmatter import parse
|
|
18
21
|
from caddis_workstreams import _artifact, _name
|
|
19
22
|
except ModuleNotFoundError as exc:
|
|
20
23
|
if exc.name not in {"caddis_exit", "caddis_file_lock", "caddis_frontmatter", "caddis_workstreams"}:
|
|
21
24
|
raise
|
|
25
|
+
# caddis_tidy imports caddis_file_lock after this module: these two folders stay on the path.
|
|
22
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
27
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
24
28
|
import caddis_exit
|
|
25
|
-
from
|
|
26
|
-
from caddis_frontmatter import parse, split_document
|
|
29
|
+
from caddis_frontmatter import parse
|
|
27
30
|
from caddis_workstreams import _artifact, _name
|
|
28
31
|
|
|
29
32
|
|
|
@@ -31,81 +34,9 @@ except ModuleNotFoundError as exc:
|
|
|
31
34
|
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
32
35
|
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
33
36
|
|
|
34
|
-
_ITEM = re.compile(r'^- \[([ x])\] (.+?) <!-- caddis: (\{.*\}) -->$')
|
|
35
37
|
_PHASE = re.compile(r'^\|\s*(\d+)\s*\|', re.M)
|
|
36
|
-
_WORD = re.compile(r'[a-z0-9]{3,}')
|
|
37
38
|
_PLAN_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,150}")
|
|
38
|
-
|
|
39
|
-
_STATUS_MARKER = re.compile("[\\s\u2300-\u27bf\U0001f300-\U0001faff\ufe0f]+$")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _path(root: Path, workstream: str | None) -> tuple[Path, Path]:
|
|
43
|
-
artifact = _artifact(root)
|
|
44
|
-
name = _name(workstream) if workstream is not None else "inbox"
|
|
45
|
-
folder = artifact / "todo-list"
|
|
46
|
-
# Only a folder that EXISTS can point elsewhere. Resolving one that does not exist yet raced a concurrent
|
|
47
|
-
# writer creating it: on Windows the two paths could resolve differently (a short 8.3 name on one side), and a
|
|
48
|
-
# safe add failed with this error (main CI, 20 Oct 2026, test_concurrent_inbox_add_loses_no_item).
|
|
49
|
-
if folder.is_symlink() or (folder.exists() and not folder.resolve().is_relative_to(artifact.resolve())):
|
|
50
|
-
raise ValueError("todo-list path escapes artifact root")
|
|
51
|
-
path = folder / f"{name}.md"
|
|
52
|
-
# Reads are unlocked, so refuse here as well as in locked_path. On Windows a writer replacing the file
|
|
53
|
-
# makes this probe (and the read below) fail with PermissionError for an instant, so both retry.
|
|
54
|
-
if retry_denied(path.is_symlink):
|
|
55
|
-
raise ValueError("to-do file may not be a symlink")
|
|
56
|
-
return artifact, path
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _read(path: Path, workstream: str | None) -> list[dict]:
|
|
60
|
-
if not retry_denied(path.exists):
|
|
61
|
-
return []
|
|
62
|
-
text = retry_denied(lambda: path.read_text(encoding="utf-8"))
|
|
63
|
-
header, body = split_document(text)
|
|
64
|
-
name = workstream or "inbox"
|
|
65
|
-
if header.get("type") != "todo" or header.get("workstream") != name or header.get("status") not in {"current", "done"}:
|
|
66
|
-
raise ValueError(f"invalid to-do header: {path}")
|
|
67
|
-
items = []
|
|
68
|
-
for line in body.splitlines():
|
|
69
|
-
if not line.strip() or line == "# To-dos":
|
|
70
|
-
continue
|
|
71
|
-
match = _ITEM.fullmatch(line)
|
|
72
|
-
if not match:
|
|
73
|
-
raise ValueError(f"invalid to-do row: {path}")
|
|
74
|
-
data = json.loads(match.group(3))
|
|
75
|
-
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
|
|
76
|
-
raise ValueError(f"invalid to-do item: {path}")
|
|
77
|
-
uuid.UUID(data["id"])
|
|
78
|
-
if "resolves" in data:
|
|
79
|
-
_resolves_path(path.parents[2], data["resolves"])
|
|
80
|
-
items.append({"id": data["id"], "title": match.group(2), "done": match.group(1) == "x",
|
|
81
|
-
**{key: data[key] for key in ("resolves", "partial") if key in data}})
|
|
82
|
-
if len({item["id"] for item in items}) != len(items):
|
|
83
|
-
raise ValueError(f"duplicate to-do id: {path}")
|
|
84
|
-
expected = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
|
|
85
|
-
if header["status"] != expected:
|
|
86
|
-
raise ValueError(f"to-do status mismatch: {path}")
|
|
87
|
-
return items
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _write(path: Path, root: Path, workstream: str | None, items: list[dict]) -> None:
|
|
91
|
-
status = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
|
|
92
|
-
lines = ["---", "type: todo", f"status: {status}", f"workstream: {workstream or 'inbox'}",
|
|
93
|
-
"---", "", "# To-dos", ""]
|
|
94
|
-
for item in items:
|
|
95
|
-
metadata = {"id": item["id"]}
|
|
96
|
-
for key in ("resolves", "partial"):
|
|
97
|
-
if key in item:
|
|
98
|
-
metadata[key] = item[key]
|
|
99
|
-
box = "x" if item["done"] else " "
|
|
100
|
-
lines.append(f'- [{box}] {item["title"]} <!-- caddis: {json.dumps(metadata, sort_keys=True)} -->')
|
|
101
|
-
atomic_write(path, "\n".join(lines) + "\n", root=root)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _title(value: str) -> str:
|
|
105
|
-
title = " ".join(value.split())
|
|
106
|
-
if not title or len(title) > 300 or "<!--" in title or "-->" in title:
|
|
107
|
-
raise ValueError("to-do text must be one plain line (1-300 characters)")
|
|
108
|
-
return title
|
|
39
|
+
RECONCILE_RETIRED = "caddis_todo: reconcile is retired (3-store model); nothing to do"
|
|
109
40
|
|
|
110
41
|
|
|
111
42
|
def _resolves_path(root: Path, value: str) -> Path:
|
|
@@ -123,58 +54,6 @@ def _resolves_path(root: Path, value: str) -> Path:
|
|
|
123
54
|
return target
|
|
124
55
|
|
|
125
56
|
|
|
126
|
-
def _suggest(root: Path, title: str) -> list[str]:
|
|
127
|
-
parking = _artifact(root) / "parking-lot"
|
|
128
|
-
if not parking.is_dir() or parking.is_symlink():
|
|
129
|
-
return []
|
|
130
|
-
words = set(_WORD.findall(title.lower()))
|
|
131
|
-
found = []
|
|
132
|
-
for path in sorted(parking.glob("*.md")):
|
|
133
|
-
if path.is_symlink() or path.name == "README.md":
|
|
134
|
-
continue
|
|
135
|
-
if words & set(_WORD.findall(path.stem.lower())) or path.name.lower() in title.lower():
|
|
136
|
-
found.append(f"parking-lot/{path.name}")
|
|
137
|
-
return found
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def add(root: Path, title: str, workstream: str | None = None, *, resolves: str | None = None,
|
|
141
|
-
partial: bool = False) -> dict:
|
|
142
|
-
title = _title(title)
|
|
143
|
-
artifact, path = _path(root, workstream)
|
|
144
|
-
if partial and not resolves:
|
|
145
|
-
raise ValueError("--partial requires --resolves")
|
|
146
|
-
linked = f"parking-lot/{_resolves_path(root, resolves).name}" if resolves else None
|
|
147
|
-
with locked_path(path, root=artifact):
|
|
148
|
-
items = _read(path, workstream)
|
|
149
|
-
item = {"id": str(uuid.uuid4()), "title": title, "done": False}
|
|
150
|
-
if linked:
|
|
151
|
-
item["resolves"] = linked
|
|
152
|
-
if partial:
|
|
153
|
-
item["partial"] = True
|
|
154
|
-
items.append(item)
|
|
155
|
-
_write(path, artifact, workstream, items)
|
|
156
|
-
return item
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def done(root: Path, item_id: str, workstream: str) -> dict:
|
|
160
|
-
artifact, path = _path(root, workstream)
|
|
161
|
-
with locked_path(path, root=artifact):
|
|
162
|
-
items = _read(path, workstream)
|
|
163
|
-
matches = [item for item in items if item["id"] == item_id]
|
|
164
|
-
if not matches:
|
|
165
|
-
raise ValueError("unknown to-do id")
|
|
166
|
-
item = matches[0]
|
|
167
|
-
if not item["done"]:
|
|
168
|
-
item["done"] = True
|
|
169
|
-
_write(path, artifact, workstream, items)
|
|
170
|
-
return item
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def list_items(root: Path, workstream: str | None = None) -> list[dict]:
|
|
174
|
-
_, path = _path(root, workstream)
|
|
175
|
-
return [item for item in _read(path, workstream) if not item["done"]]
|
|
176
|
-
|
|
177
|
-
|
|
178
57
|
def _is_done(status: str) -> bool:
|
|
179
58
|
"""A Tracker status cell that means done: "done", "done (note)" or a leading check mark.
|
|
180
59
|
A cell that says work is still pending is not done (owner decision 2026-09-27)."""
|
|
@@ -276,7 +155,7 @@ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
|
|
|
276
155
|
|
|
277
156
|
None when there is no Tracker heading line, no `| Phase |` header or no Status column. The
|
|
278
157
|
lines are every line of the section except the header; callers pick the rows they trust.
|
|
279
|
-
One reader, so
|
|
158
|
+
One reader, so caddis_tidy and any later caller never disagree about the same table.
|
|
280
159
|
"""
|
|
281
160
|
# A real heading line only: plan prose may mention "## Tracker" inline before the section.
|
|
282
161
|
heading = re.search(r"^## Tracker\b.*$", text, re.M)
|
|
@@ -293,235 +172,39 @@ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
|
|
|
293
172
|
return columns, table[:at] + table[at + 1:]
|
|
294
173
|
|
|
295
174
|
|
|
296
|
-
def _plan_rows(plan: Path | None) -> list[dict]:
|
|
297
|
-
if plan is None:
|
|
298
|
-
return []
|
|
299
|
-
text = plan.read_text(encoding="utf-8")
|
|
300
|
-
table = tracker_table(text)
|
|
301
|
-
if table is None:
|
|
302
|
-
return []
|
|
303
|
-
columns, lines = table
|
|
304
|
-
rows = []
|
|
305
|
-
for line in lines:
|
|
306
|
-
match = _PHASE.match(line)
|
|
307
|
-
if not match:
|
|
308
|
-
continue
|
|
309
|
-
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
|
310
|
-
if len(cells) != len(columns) or _is_done(cells[columns.index("status")]):
|
|
311
|
-
continue
|
|
312
|
-
number = match.group(1)
|
|
313
|
-
heading = re.search(rf"^### Phase {number}\s+[-—]\s+(.+)$", text, re.M)
|
|
314
|
-
# Drop the heading's trailing status marker (🔲, ⏳, ✅ ...): status lives in the Tracker.
|
|
315
|
-
title = _STATUS_MARKER.sub("", heading.group(1)) if heading else f"Phase {number}"
|
|
316
|
-
rows.append({"id": f"phase:{number}", "kind": "phase", "title": title})
|
|
317
|
-
return rows
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def _split_cells(line: str) -> list[str]:
|
|
321
|
-
r"""Stripped cells of one table line, split on unescaped pipes only (`a \| b` is one cell)."""
|
|
322
|
-
parts = re.split(r"(?<!\\)\|", line.strip())
|
|
323
|
-
if parts and parts[0] == "":
|
|
324
|
-
parts.pop(0)
|
|
325
|
-
if parts and parts[-1] == "":
|
|
326
|
-
parts.pop(-1)
|
|
327
|
-
return [part.strip() for part in parts]
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def _open_work_rows(root: Path) -> list[dict]:
|
|
331
|
-
path = _artifact(root) / "open-work.md"
|
|
332
|
-
if path.is_symlink() or not path.is_file():
|
|
333
|
-
return []
|
|
334
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
335
|
-
header, body = split_document(text)
|
|
336
|
-
if header.get("type") != "open-work":
|
|
337
|
-
return []
|
|
338
|
-
rows: list[dict] = []
|
|
339
|
-
columns: list[str] | None = None
|
|
340
|
-
id_idx = status_idx = title_idx = 0
|
|
341
|
-
for raw_line in body.splitlines():
|
|
342
|
-
line = raw_line.strip()
|
|
343
|
-
if not line.startswith("|"):
|
|
344
|
-
columns = None
|
|
345
|
-
continue
|
|
346
|
-
cells = _split_cells(line) # unescaped pipes only, as caddis_rows.split_cells
|
|
347
|
-
if not cells or all(c and set(c) <= set("-: ") and "-" in c for c in cells):
|
|
348
|
-
continue
|
|
349
|
-
if columns is None:
|
|
350
|
-
lowered = [c.lower() for c in cells]
|
|
351
|
-
if "id" in lowered and "status" in lowered:
|
|
352
|
-
columns = lowered
|
|
353
|
-
id_idx = columns.index("id")
|
|
354
|
-
status_idx = columns.index("status")
|
|
355
|
-
title_idx = next(
|
|
356
|
-
(i for i, col in enumerate(columns) if col in {"task", "title", "item"}),
|
|
357
|
-
id_idx + 1,
|
|
358
|
-
)
|
|
359
|
-
continue
|
|
360
|
-
if len(cells) != len(columns):
|
|
361
|
-
continue
|
|
362
|
-
status = cells[status_idx].strip().lower()
|
|
363
|
-
first = status.split(" ", 1)[0] if status else ""
|
|
364
|
-
if _is_done(status) or first in {"not-needed", "✓", "✔"}:
|
|
365
|
-
continue
|
|
366
|
-
row_id = cells[id_idx].replace("**", "").replace("__", "").replace("`", "").strip()
|
|
367
|
-
if not row_id:
|
|
368
|
-
continue
|
|
369
|
-
title = cells[title_idx] if title_idx < len(cells) else ""
|
|
370
|
-
rows.append({"id": row_id, "kind": "open-work", "title": title})
|
|
371
|
-
return rows
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def open_work(root: Path, workstream: str | None = None) -> list[dict]:
|
|
375
|
-
rows = _plan_rows(_active_plan(root, workstream))
|
|
376
|
-
for name in (None, workstream) if workstream and workstream != "inbox" else (None,):
|
|
377
|
-
rows.extend({"id": item["id"], "kind": "todo", "title": item["title"],
|
|
378
|
-
**{key: item[key] for key in ("resolves", "partial") if key in item}}
|
|
379
|
-
for item in list_items(root, name))
|
|
380
|
-
rows.extend(_open_work_rows(root))
|
|
381
|
-
return rows
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def _extract_completed_ids(text: str) -> set[str]:
|
|
385
|
-
"""Open-work ids of the done rows in a session-state file's "## Tasks" table.
|
|
386
|
-
|
|
387
|
-
Only the marker session_state.py writes after a task's subject counts, as in
|
|
388
|
-
``| 3 | done | Fix x <!-- caddis: {"id": "..."} --> |``. A UUID that merely appears in a
|
|
389
|
-
task's text is never trusted: "depends on <uuid>" must not close that other item.
|
|
390
|
-
"""
|
|
391
|
-
ids: set[str] = set()
|
|
392
|
-
in_tasks = False
|
|
393
|
-
for line in text.splitlines():
|
|
394
|
-
row = line.strip()
|
|
395
|
-
if row.startswith("## "):
|
|
396
|
-
in_tasks = row == "## Tasks"
|
|
397
|
-
continue
|
|
398
|
-
if not in_tasks or not row.startswith("|") or row.startswith("|---"):
|
|
399
|
-
continue
|
|
400
|
-
cells = [cell.strip() for cell in row.strip("|").split("|")]
|
|
401
|
-
# Status is the second cell; a "|" inside the subject only shifts later cells.
|
|
402
|
-
if len(cells) < 3 or cells[1].lower() not in {"done", "completed"}:
|
|
403
|
-
continue
|
|
404
|
-
marker = re.search(r"<!--\s*caddis:\s*(\{.*?\})\s*-->\s*\|?$", row)
|
|
405
|
-
if not marker:
|
|
406
|
-
continue
|
|
407
|
-
try:
|
|
408
|
-
value = json.loads(marker.group(1)).get("id")
|
|
409
|
-
except (ValueError, AttributeError):
|
|
410
|
-
continue
|
|
411
|
-
if isinstance(value, str) and value:
|
|
412
|
-
ids.add(value.lower())
|
|
413
|
-
return ids
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def reconcile(root: Path, session_state: Path) -> list[str]:
|
|
417
|
-
"""Reconcile completed task rows from session-state into todo-lists.
|
|
418
|
-
|
|
419
|
-
Marks ad-hoc todo items done by metadata ID.
|
|
420
|
-
Never edits the Tracker; phase rows are ignored.
|
|
421
|
-
"""
|
|
422
|
-
if not session_state.is_file():
|
|
423
|
-
return []
|
|
424
|
-
|
|
425
|
-
text = session_state.read_text(encoding="utf-8", errors="replace")
|
|
426
|
-
completed_ids = _extract_completed_ids(text)
|
|
427
|
-
if not completed_ids:
|
|
428
|
-
return []
|
|
429
|
-
|
|
430
|
-
artifact = _artifact(root)
|
|
431
|
-
todo_dir = artifact / "todo-list"
|
|
432
|
-
if not todo_dir.is_dir():
|
|
433
|
-
return []
|
|
434
|
-
|
|
435
|
-
reconciled: list[str] = []
|
|
436
|
-
# Ignore phase rows — Tracker is never edited!
|
|
437
|
-
adhoc_ids = {cid for cid in completed_ids if not cid.startswith("phase:")}
|
|
438
|
-
if not adhoc_ids:
|
|
439
|
-
return []
|
|
440
|
-
|
|
441
|
-
wanted = {a.lower() for a in adhoc_ids}
|
|
442
|
-
for todo_file in todo_dir.glob("*.md"):
|
|
443
|
-
if todo_file.is_symlink() or not todo_file.is_file():
|
|
444
|
-
continue
|
|
445
|
-
workstream_name = todo_file.stem
|
|
446
|
-
try:
|
|
447
|
-
items = _read(todo_file, workstream_name)
|
|
448
|
-
except (ValueError, OSError):
|
|
449
|
-
continue # a hand-edited file caddis cannot read; the others still reconcile
|
|
450
|
-
for item in items:
|
|
451
|
-
item_id = item.get("id")
|
|
452
|
-
if item_id and item_id.lower() in wanted and not item.get("done"):
|
|
453
|
-
done(root, item_id, workstream_name)
|
|
454
|
-
reconciled.append(item_id)
|
|
455
|
-
|
|
456
|
-
return reconciled
|
|
457
|
-
|
|
458
|
-
|
|
459
175
|
def main(argv: list[str] | None = None) -> int:
|
|
460
|
-
#
|
|
176
|
+
# Plan paths may carry non-ASCII text; a Windows console defaults to a legacy code page.
|
|
461
177
|
for stream in (sys.stdout, sys.stderr):
|
|
462
178
|
if hasattr(stream, "reconfigure"):
|
|
463
179
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
464
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
180
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
465
181
|
parser.add_argument("--root", type=Path, default=Path.cwd(), help=argparse.SUPPRESS)
|
|
466
182
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
467
|
-
listing = commands.add_parser("list")
|
|
468
|
-
listing.add_argument("workstream", nargs="?")
|
|
469
|
-
adding = commands.add_parser("add")
|
|
470
|
-
adding.add_argument("text")
|
|
471
|
-
adding.add_argument("--workstream")
|
|
472
|
-
adding.add_argument("--resolves")
|
|
473
|
-
adding.add_argument("--partial", action="store_true")
|
|
474
|
-
finishing = commands.add_parser("done")
|
|
475
|
-
finishing.add_argument("id")
|
|
476
|
-
finishing.add_argument("--workstream", required=True)
|
|
477
|
-
view = commands.add_parser("open-work")
|
|
478
|
-
view.add_argument("--workstream")
|
|
479
|
-
view.add_argument("--json", action="store_true")
|
|
480
|
-
reconcile_p = commands.add_parser("reconcile")
|
|
481
|
-
reconcile_p.add_argument("--session-state", type=Path, required=True)
|
|
482
183
|
commands.add_parser("active-plan")
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
elif args.command == "done":
|
|
492
|
-
item = done(args.root, args.id, args.workstream)
|
|
493
|
-
if "resolves" in item:
|
|
494
|
-
prefix = "Partly-Resolves-Parked" if item.get("partial") else "Resolves-Parked"
|
|
495
|
-
print(f'{prefix}: {item["resolves"]}')
|
|
496
|
-
elif args.command == "list":
|
|
497
|
-
for item in list_items(args.root, args.workstream):
|
|
498
|
-
print(f'{item["id"]} {item["title"]}')
|
|
499
|
-
elif args.command == "active-plan":
|
|
500
|
-
plan, candidates = active_plan(args.root)
|
|
501
|
-
if plan is None:
|
|
502
|
-
if candidates:
|
|
503
|
-
print("caddis-todo: several current plans; name one:", file=sys.stderr)
|
|
504
|
-
for candidate in candidates:
|
|
505
|
-
print(f" {candidate}", file=sys.stderr)
|
|
506
|
-
else:
|
|
507
|
-
print("caddis-todo: no current plan in .caddis/plans", file=sys.stderr)
|
|
508
|
-
return caddis_exit.NOT_RUN
|
|
509
|
-
print(plan)
|
|
510
|
-
elif args.command == "reconcile":
|
|
511
|
-
reconciled = reconcile(args.root, args.session_state)
|
|
512
|
-
for rid in reconciled:
|
|
513
|
-
print(f"Reconciled: {rid}")
|
|
514
|
-
else:
|
|
515
|
-
rows = open_work(args.root, args.workstream)
|
|
516
|
-
if args.json:
|
|
517
|
-
print(json.dumps(rows, ensure_ascii=False))
|
|
518
|
-
else:
|
|
519
|
-
for row in rows:
|
|
520
|
-
print(f'{row["id"]} [{row["kind"]}] {row["title"]}')
|
|
184
|
+
# Retired: kept one release so an older /handoff Step 6 does not fail. Takes its old flag, ignores it.
|
|
185
|
+
reconcile_p = commands.add_parser("reconcile", help=argparse.SUPPRESS)
|
|
186
|
+
reconcile_p.add_argument("--session-state", type=Path)
|
|
187
|
+
args, extra = parser.parse_known_args(argv)
|
|
188
|
+
if extra and args.command != "reconcile":
|
|
189
|
+
parser.error(f"unrecognized arguments: {' '.join(extra)}") # only the retired no-op tolerates old flags
|
|
190
|
+
if args.command == "reconcile":
|
|
191
|
+
print(RECONCILE_RETIRED)
|
|
521
192
|
return 0
|
|
522
|
-
|
|
193
|
+
try:
|
|
194
|
+
plan, candidates = active_plan(args.root)
|
|
195
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
523
196
|
print(f"caddis-todo: {exc}", file=sys.stderr)
|
|
524
197
|
return 1
|
|
198
|
+
if plan is None:
|
|
199
|
+
if candidates:
|
|
200
|
+
print("caddis-todo: several current plans; name one:", file=sys.stderr)
|
|
201
|
+
for candidate in candidates:
|
|
202
|
+
print(f" {candidate}", file=sys.stderr)
|
|
203
|
+
else:
|
|
204
|
+
print("caddis-todo: no current plan in .caddis/plans", file=sys.stderr)
|
|
205
|
+
return caddis_exit.NOT_RUN
|
|
206
|
+
print(plan)
|
|
207
|
+
return 0
|
|
525
208
|
|
|
526
209
|
|
|
527
210
|
if __name__ == "__main__":
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The `flow-studio` skill: a model file (JSON) describes a flow, lifecycle or journey, and one
|
|
4
4
|
engine draws it as a self-contained HTML page with pan and zoom, lanes, linked nodes, a slide-in
|
|
5
5
|
inspector, scenario playback, metric overlays, live update, motion that carries state, and numbered implementation notes. The design is in
|
|
6
|
-
`.caddis/plans/flow-studio-skill-design.md`; the S4 stage status is in `.caddis/plans/flow-studio-s4-status.md`.
|
|
6
|
+
`.caddis/plans/done/flow-studio-skill-design.md`; the S4 stage status is in `.caddis/plans/flow-studio-s4-status.md`.
|
|
7
7
|
`SKILL.md` is the current playbook.
|
|
8
8
|
|
|
9
9
|
## Use it
|
|
@@ -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", () => {
|