@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.
@@ -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, the same reader the open-work view uses, and
159
- a status cell is judged by the same `_is_done`. Stricter here on purpose: that view skips a
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` prints) or by its repo-relative path. The last word is
476
- caddis_todo._resolves_path, the check `caddis_todo add --resolves` already applies."""
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, or a plan's Tracker is all done while its status is active")
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
- """Locked, local to-do lists and a read-only view of active plan work."""
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 caddis_file_lock import atomic_write, locked_path, retry_denied
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 caddis_file_lock import atomic_write, locked_path, retry_denied
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
- # Trailing status markers on phase headings: arrows/clocks (U+2300-U+27BF), emoji, VS16.
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 the open-work view and caddis_tidy never disagree about the same table.
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
- # Titles carry non-ASCII text; a Windows console defaults to a legacy code page.
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
- args = parser.parse_args(argv)
484
- try:
485
- if args.command == "add":
486
- item = add(args.root, args.text, args.workstream, resolves=args.resolves, partial=args.partial)
487
- print(item["id"])
488
- if not args.resolves:
489
- for suggestion in _suggest(args.root, item["title"]):
490
- print(f"Suggested parking item: {suggestion}")
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
- except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
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", () => {