@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
|
@@ -52,12 +52,6 @@ BUDGETS: tuple[Budget, ...] = (
|
|
|
52
52
|
60,
|
|
53
53
|
"rendered by render-index; drop relays with status closed",
|
|
54
54
|
),
|
|
55
|
-
Budget(
|
|
56
|
-
".caddis/open-work.md",
|
|
57
|
-
"rows with Status done",
|
|
58
|
-
10,
|
|
59
|
-
"move done rows to .caddis/done/open-work-archive.md with a one-line pointer",
|
|
60
|
-
),
|
|
61
55
|
Budget(
|
|
62
56
|
"DECISIONS.md, .caddis/DECISIONS.md, .caddis/decisions/DECISIONS.md", # the last is the fleet's real path
|
|
63
57
|
"History table rows",
|
|
@@ -150,36 +144,6 @@ def _count_table_rows(text: str) -> int:
|
|
|
150
144
|
return sum(len(t.data_rows) for t in tables) + loose_rows
|
|
151
145
|
|
|
152
146
|
|
|
153
|
-
def _norm_status(cell: str) -> str:
|
|
154
|
-
return cell.replace("**", "").replace("__", "").replace("`", "").strip().lower()
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _count_done_rows(text: str) -> int:
|
|
158
|
-
tables, _ = _parse_tables(text)
|
|
159
|
-
done_re = re.compile(r"^done\b")
|
|
160
|
-
count = 0
|
|
161
|
-
if tables:
|
|
162
|
-
for t in tables:
|
|
163
|
-
col_map = {c.lower(): idx for idx, c in enumerate(t.columns)}
|
|
164
|
-
if "status" in col_map:
|
|
165
|
-
s_idx = col_map["status"]
|
|
166
|
-
for row in t.data_rows:
|
|
167
|
-
if len(row) > s_idx and done_re.search(_norm_status(row[s_idx])):
|
|
168
|
-
count += 1
|
|
169
|
-
else:
|
|
170
|
-
for row in t.data_rows:
|
|
171
|
-
if any(_norm_status(c) == "done" for c in row):
|
|
172
|
-
count += 1
|
|
173
|
-
return count
|
|
174
|
-
|
|
175
|
-
for line in text.splitlines():
|
|
176
|
-
if line.lstrip().startswith("|") and not _is_separator(line):
|
|
177
|
-
cells = _split_cells(line)
|
|
178
|
-
if any(_norm_status(c) == "done" for c in cells):
|
|
179
|
-
count += 1
|
|
180
|
-
return count
|
|
181
|
-
|
|
182
|
-
|
|
183
147
|
def _count_history_rows(text: str) -> int:
|
|
184
148
|
tables, loose_rows = _parse_tables(text)
|
|
185
149
|
history_tables = [t for t in tables if t.heading and "history" in t.heading.lower()]
|
|
@@ -198,8 +162,6 @@ def _measure_file(path: Path, unit: str) -> int:
|
|
|
198
162
|
return _count_table_rows(text)
|
|
199
163
|
if unit in ("lines", "lines per file"):
|
|
200
164
|
return len(text.splitlines())
|
|
201
|
-
if unit == "rows with Status done":
|
|
202
|
-
return _count_done_rows(text)
|
|
203
165
|
if unit == "History table rows":
|
|
204
166
|
return _count_history_rows(text)
|
|
205
167
|
raise ValueError(f"unknown budget unit: {unit}")
|
|
@@ -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)})
|
|
@@ -11,7 +11,7 @@ line; decide. CONTINUE with an unchanged relay is `FAILED no progress`; three se
|
|
|
11
11
|
row is `BLOCKED stuck`.
|
|
12
12
|
|
|
13
13
|
Plan mode (`--plan <slug>`, decision D-I, design section 8.5) runs `.caddis/plans/<slug>.md`. A closed row in its
|
|
14
|
-
tracker
|
|
14
|
+
tracker is progress (`.caddis/open-work.md` is retired with the 3-store model, 21 Oct 2026); the relay is `.caddis/relay/<slug>.md`; the log and STOP file are in
|
|
15
15
|
`.caddis/plans/.local/<slug>/`; the lock is `<git common dir>/caddis-runner/plan-<slug>/runner.lock`. The loop goes on only
|
|
16
16
|
at CONTINUE. Its lock holds the whole plan (`batch` `<slug>#*`). A plan that another live runner lock names is refused
|
|
17
17
|
(exit 3 at start, 1 at any later iteration: the check runs before every session).
|
|
@@ -322,8 +322,7 @@ def resolve_target(repo: Path, plan: str | None) -> Target:
|
|
|
322
322
|
plan_file = repo / ".caddis" / "plans" / f"{slug}.md"
|
|
323
323
|
if not plan_file.is_file():
|
|
324
324
|
raise ValueError(f"no plan {slug!r}: {plan_file} does not exist")
|
|
325
|
-
return Target("plan", slug, _local_dir(repo, "plan", slug), (plan_file,
|
|
326
|
-
PLAN_PROMPT_NAME)
|
|
325
|
+
return Target("plan", slug, _local_dir(repo, "plan", slug), (plan_file,), PLAN_PROMPT_NAME)
|
|
327
326
|
|
|
328
327
|
|
|
329
328
|
def render_prompt(template: Path, slug: str, phase: int | None = None) -> str:
|
|
@@ -337,13 +336,13 @@ def render_prompt(template: Path, slug: str, phase: int | None = None) -> str:
|
|
|
337
336
|
return body.strip() + "\n"
|
|
338
337
|
|
|
339
338
|
|
|
340
|
-
# --
|
|
339
|
+
# -- closed rows ------------------------------------------------------------------------------------------------------
|
|
341
340
|
|
|
342
341
|
|
|
343
|
-
def closed_rows(
|
|
342
|
+
def closed_rows(path: Path) -> int:
|
|
344
343
|
"""Rows whose `Status` column (by name) is done or not-needed, over every table of the file."""
|
|
345
344
|
try:
|
|
346
|
-
lines =
|
|
345
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
347
346
|
except OSError:
|
|
348
347
|
return 0
|
|
349
348
|
count, status_col = 0, None
|
|
@@ -1,34 +1,16 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Split a markdown table line into cells (caddis_roadmap reads its tables with this).
|
|
2
|
+
|
|
3
|
+
The `set` and `archive-done` sub-commands edited `.caddis/open-work.md`. They are retired with the
|
|
4
|
+
3-store model (owner decision, 21 Oct 2026): caddis no longer reads or writes open-work.md. Running the
|
|
5
|
+
script prints one line and changes nothing, so an older /handoff that still calls `set` does not fail.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
|
-
import argparse
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
from pathlib import Path
|
|
10
10
|
import re
|
|
11
11
|
import sys
|
|
12
|
-
import tempfile
|
|
13
|
-
|
|
14
|
-
try:
|
|
15
|
-
import caddis_exit
|
|
16
|
-
except ModuleNotFoundError as exc:
|
|
17
|
-
if exc.name != "caddis_exit":
|
|
18
|
-
raise
|
|
19
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
21
|
-
import caddis_exit
|
|
22
|
-
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
class Table:
|
|
26
|
-
heading: str | None
|
|
27
|
-
header_line_idx: int
|
|
28
|
-
separator_line_idx: int
|
|
29
|
-
data_line_indices: list[int]
|
|
30
|
-
columns: list[str]
|
|
31
|
-
id_col_idx: int | None
|
|
13
|
+
RETIRED = "caddis_rows: set and archive-done are retired (3-store model); nothing changed"
|
|
32
14
|
|
|
33
15
|
|
|
34
16
|
def split_cells(line: str) -> list[str]:
|
|
@@ -44,433 +26,10 @@ def split_cells(line: str) -> list[str]:
|
|
|
44
26
|
return [p.strip() for p in parts]
|
|
45
27
|
|
|
46
28
|
|
|
47
|
-
def _is_separator(line: str) -> bool:
|
|
48
|
-
cells = split_cells(line)
|
|
49
|
-
if not cells:
|
|
50
|
-
return False
|
|
51
|
-
for cell in cells:
|
|
52
|
-
c = cell.strip()
|
|
53
|
-
if not c:
|
|
54
|
-
return False
|
|
55
|
-
if any(ch not in "-: " for ch in c):
|
|
56
|
-
return False
|
|
57
|
-
if "-" not in c:
|
|
58
|
-
return False
|
|
59
|
-
return True
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def find_tables(lines: list[str]) -> list[Table]:
|
|
63
|
-
tables: list[Table] = []
|
|
64
|
-
n = len(lines)
|
|
65
|
-
i = 0
|
|
66
|
-
while i < n:
|
|
67
|
-
if lines[i].startswith("|"):
|
|
68
|
-
run_start = i
|
|
69
|
-
while i < n and lines[i].startswith("|"):
|
|
70
|
-
i += 1
|
|
71
|
-
run_end = i
|
|
72
|
-
run_lines = lines[run_start:run_end]
|
|
73
|
-
if len(run_lines) >= 2 and _is_separator(run_lines[1]):
|
|
74
|
-
header_idx = run_start
|
|
75
|
-
sep_idx = run_start + 1
|
|
76
|
-
data_indices = list(range(run_start + 2, run_end))
|
|
77
|
-
columns = split_cells(lines[header_idx])
|
|
78
|
-
|
|
79
|
-
heading = None
|
|
80
|
-
for k in range(run_start - 1, -1, -1):
|
|
81
|
-
s = lines[k].strip()
|
|
82
|
-
if s.startswith("#"):
|
|
83
|
-
heading = s.lstrip("#").strip()
|
|
84
|
-
break
|
|
85
|
-
|
|
86
|
-
id_col_idx = None
|
|
87
|
-
for col_idx, col in enumerate(columns):
|
|
88
|
-
if col.lower() == "id":
|
|
89
|
-
id_col_idx = col_idx
|
|
90
|
-
break
|
|
91
|
-
|
|
92
|
-
tables.append(
|
|
93
|
-
Table(
|
|
94
|
-
heading=heading,
|
|
95
|
-
header_line_idx=header_idx,
|
|
96
|
-
separator_line_idx=sep_idx,
|
|
97
|
-
data_line_indices=data_indices,
|
|
98
|
-
columns=columns,
|
|
99
|
-
id_col_idx=id_col_idx,
|
|
100
|
-
)
|
|
101
|
-
)
|
|
102
|
-
else:
|
|
103
|
-
i += 1
|
|
104
|
-
return tables
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _atomic_write_bytes(target: Path, data: bytes) -> None:
|
|
108
|
-
target = target.resolve()
|
|
109
|
-
temp_path: Path | None = None
|
|
110
|
-
try:
|
|
111
|
-
with tempfile.NamedTemporaryFile(
|
|
112
|
-
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False
|
|
113
|
-
) as f:
|
|
114
|
-
temp_path = Path(f.name)
|
|
115
|
-
f.write(data)
|
|
116
|
-
f.flush()
|
|
117
|
-
os.fsync(f.fileno())
|
|
118
|
-
os.replace(temp_path, target)
|
|
119
|
-
temp_path = None
|
|
120
|
-
finally:
|
|
121
|
-
if temp_path is not None and temp_path.exists():
|
|
122
|
-
try:
|
|
123
|
-
temp_path.unlink()
|
|
124
|
-
except OSError:
|
|
125
|
-
pass
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _norm_id(cell: str) -> str:
|
|
129
|
-
return cell.replace("**", "").replace("__", "").replace("`", "").strip()
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def handle_set(args: argparse.Namespace) -> int:
|
|
133
|
-
if not args.id and not args.from_file:
|
|
134
|
-
print("caddis-rows: either --id or --from is required", file=sys.stderr)
|
|
135
|
-
return caddis_exit.ERROR
|
|
136
|
-
if args.id and not args.col:
|
|
137
|
-
print("caddis-rows: --id requires at least one --col", file=sys.stderr)
|
|
138
|
-
return caddis_exit.ERROR
|
|
139
|
-
if args.id and args.from_file:
|
|
140
|
-
print("caddis-rows: cannot specify both --id and --from", file=sys.stderr)
|
|
141
|
-
return caddis_exit.ERROR
|
|
142
|
-
|
|
143
|
-
change_specs: list[dict] = []
|
|
144
|
-
if args.id:
|
|
145
|
-
cols_dict: dict[str, str] = {}
|
|
146
|
-
for c in args.col:
|
|
147
|
-
if "=" not in c:
|
|
148
|
-
print(f"caddis-rows: invalid --col format '{c}', expected NAME=VALUE", file=sys.stderr)
|
|
149
|
-
return caddis_exit.ERROR
|
|
150
|
-
name, val = c.split("=", 1)
|
|
151
|
-
name = name.strip()
|
|
152
|
-
cols_dict[name] = val
|
|
153
|
-
change_specs.append({"id": args.id, "table": args.table, "cols": cols_dict})
|
|
154
|
-
else:
|
|
155
|
-
from_path = Path(args.from_file)
|
|
156
|
-
if from_path.is_file():
|
|
157
|
-
try:
|
|
158
|
-
content = from_path.read_text(encoding="utf-8")
|
|
159
|
-
loaded = json.loads(content)
|
|
160
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
161
|
-
print(f"caddis-rows: failed to read --from file: {exc}", file=sys.stderr)
|
|
162
|
-
return caddis_exit.ERROR
|
|
163
|
-
else:
|
|
164
|
-
try:
|
|
165
|
-
loaded = json.loads(args.from_file)
|
|
166
|
-
except json.JSONDecodeError as exc:
|
|
167
|
-
print(f"caddis-rows: invalid JSON in --from: {exc}", file=sys.stderr)
|
|
168
|
-
return caddis_exit.ERROR
|
|
169
|
-
|
|
170
|
-
if not isinstance(loaded, list):
|
|
171
|
-
print("caddis-rows: --from JSON must be a list of change objects", file=sys.stderr)
|
|
172
|
-
return caddis_exit.ERROR
|
|
173
|
-
|
|
174
|
-
for item in loaded:
|
|
175
|
-
if not isinstance(item, dict) or "id" not in item or "cols" not in item or not isinstance(item["cols"], dict):
|
|
176
|
-
print(f"caddis-rows: invalid item in --from: {item}", file=sys.stderr)
|
|
177
|
-
return caddis_exit.ERROR
|
|
178
|
-
tbl = item.get("table") or args.table
|
|
179
|
-
change_specs.append({"id": item["id"], "table": tbl, "cols": dict(item["cols"])})
|
|
180
|
-
|
|
181
|
-
for spec in change_specs:
|
|
182
|
-
for val in spec["cols"].values():
|
|
183
|
-
val_str = str(val)
|
|
184
|
-
if "\n" in val_str or "\r" in val_str:
|
|
185
|
-
print("caddis-rows: value contains newline characters", file=sys.stderr)
|
|
186
|
-
return caddis_exit.ERROR
|
|
187
|
-
|
|
188
|
-
file_path = Path(args.file)
|
|
189
|
-
if not file_path.exists():
|
|
190
|
-
print(f"caddis-rows: file not found: {file_path}", file=sys.stderr)
|
|
191
|
-
return caddis_exit.BLOCKED
|
|
192
|
-
|
|
193
|
-
raw = file_path.read_bytes()
|
|
194
|
-
text = raw.decode("utf-8")
|
|
195
|
-
crlf = "\r\n" in text
|
|
196
|
-
newline = "\r\n" if crlf else "\n"
|
|
197
|
-
ends_with_newline = text.endswith(newline)
|
|
198
|
-
if ends_with_newline:
|
|
199
|
-
content = text[:-len(newline)]
|
|
200
|
-
else:
|
|
201
|
-
content = text
|
|
202
|
-
lines = content.split(newline) if content else []
|
|
203
|
-
|
|
204
|
-
tables = find_tables(lines)
|
|
205
|
-
|
|
206
|
-
# Validate all changes
|
|
207
|
-
validated_changes: list[dict] = []
|
|
208
|
-
for spec in change_specs:
|
|
209
|
-
item_id = spec["id"]
|
|
210
|
-
table_filter = spec.get("table")
|
|
211
|
-
cols = spec["cols"]
|
|
212
|
-
|
|
213
|
-
candidate_tables = [t for t in tables if t.id_col_idx is not None]
|
|
214
|
-
if not candidate_tables:
|
|
215
|
-
print(
|
|
216
|
-
f"caddis-rows: {file_path}: no table has a column named ID; an open-work table needs | ID | ... | Status |",
|
|
217
|
-
file=sys.stderr,
|
|
218
|
-
)
|
|
219
|
-
return caddis_exit.BLOCKED
|
|
220
|
-
if table_filter is not None:
|
|
221
|
-
candidate_tables = [t for t in candidate_tables if t.heading == table_filter]
|
|
222
|
-
|
|
223
|
-
candidates: list[tuple[Table, int, list[str]]] = []
|
|
224
|
-
for t in candidate_tables:
|
|
225
|
-
for line_idx in t.data_line_indices:
|
|
226
|
-
cells = split_cells(lines[line_idx])
|
|
227
|
-
if len(cells) > t.id_col_idx and _norm_id(cells[t.id_col_idx]) == _norm_id(item_id):
|
|
228
|
-
candidates.append((t, line_idx, cells))
|
|
229
|
-
|
|
230
|
-
if len(candidates) == 0:
|
|
231
|
-
print(f"caddis-rows: row id '{item_id}' not found", file=sys.stderr)
|
|
232
|
-
return caddis_exit.BLOCKED
|
|
233
|
-
|
|
234
|
-
if len(candidates) > 1:
|
|
235
|
-
headings = [c[0].heading for c in candidates]
|
|
236
|
-
print(f"caddis-rows: row id '{item_id}' found in multiple tables: {headings}", file=sys.stderr)
|
|
237
|
-
return caddis_exit.BLOCKED
|
|
238
|
-
|
|
239
|
-
cand_table, cand_line_idx, cand_cells = candidates[0]
|
|
240
|
-
|
|
241
|
-
col_lookup = {col.lower(): idx for idx, col in enumerate(cand_table.columns)}
|
|
242
|
-
for col_name in cols:
|
|
243
|
-
if col_name.lower() not in col_lookup:
|
|
244
|
-
print(f"caddis-rows: column '{col_name}' not found in table '{cand_table.heading}'", file=sys.stderr)
|
|
245
|
-
return caddis_exit.BLOCKED
|
|
246
|
-
|
|
247
|
-
validated_changes.append({
|
|
248
|
-
"id": item_id,
|
|
249
|
-
"table": cand_table,
|
|
250
|
-
"line_idx": cand_line_idx,
|
|
251
|
-
"cols": cols,
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
# Apply changes
|
|
255
|
-
row_cells: dict[int, list[str]] = {}
|
|
256
|
-
original_cells: dict[int, list[str]] = {}
|
|
257
|
-
applied_notifications: list[str] = []
|
|
258
|
-
|
|
259
|
-
for v in validated_changes:
|
|
260
|
-
cand_table: Table = v["table"]
|
|
261
|
-
line_idx: int = v["line_idx"]
|
|
262
|
-
cols: dict[str, str] = v["cols"]
|
|
263
|
-
item_id: str = v["id"]
|
|
264
|
-
|
|
265
|
-
if line_idx not in row_cells:
|
|
266
|
-
cells = split_cells(lines[line_idx])
|
|
267
|
-
if len(cells) < len(cand_table.columns):
|
|
268
|
-
cells.extend([""] * (len(cand_table.columns) - len(cells)))
|
|
269
|
-
row_cells[line_idx] = cells
|
|
270
|
-
original_cells[line_idx] = list(cells)
|
|
271
|
-
|
|
272
|
-
current_cells = row_cells[line_idx]
|
|
273
|
-
col_lookup = {col.lower(): idx for idx, col in enumerate(cand_table.columns)}
|
|
274
|
-
|
|
275
|
-
for col_name, raw_val in cols.items():
|
|
276
|
-
col_idx = col_lookup[col_name.lower()]
|
|
277
|
-
canonical_col_name = cand_table.columns[col_idx]
|
|
278
|
-
escaped_val = re.sub(r"(?<!\\)\|", r"\|", str(raw_val))
|
|
279
|
-
if current_cells[col_idx] != escaped_val:
|
|
280
|
-
current_cells[col_idx] = escaped_val
|
|
281
|
-
applied_notifications.append(f"set {item_id} {canonical_col_name}={raw_val}")
|
|
282
|
-
|
|
283
|
-
any_changed = False
|
|
284
|
-
for line_idx, cells in row_cells.items():
|
|
285
|
-
if cells != original_cells[line_idx]:
|
|
286
|
-
lines[line_idx] = "| " + " | ".join(cells) + " |"
|
|
287
|
-
any_changed = True
|
|
288
|
-
|
|
289
|
-
if any_changed:
|
|
290
|
-
output_text = newline.join(lines)
|
|
291
|
-
if ends_with_newline and lines:
|
|
292
|
-
output_text += newline
|
|
293
|
-
_atomic_write_bytes(file_path, output_text.encode("utf-8"))
|
|
294
|
-
|
|
295
|
-
for note in applied_notifications:
|
|
296
|
-
print(note)
|
|
297
|
-
|
|
298
|
-
return caddis_exit.CLEAN
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def handle_archive_done(args: argparse.Namespace) -> int:
|
|
302
|
-
file_path = Path(args.file)
|
|
303
|
-
if not file_path.exists():
|
|
304
|
-
print(f"caddis-rows: file not found: {file_path}", file=sys.stderr)
|
|
305
|
-
return caddis_exit.BLOCKED
|
|
306
|
-
|
|
307
|
-
raw = file_path.read_bytes()
|
|
308
|
-
text = raw.decode("utf-8")
|
|
309
|
-
crlf = "\r\n" in text
|
|
310
|
-
newline = "\r\n" if crlf else "\n"
|
|
311
|
-
ends_with_newline = text.endswith(newline)
|
|
312
|
-
if ends_with_newline:
|
|
313
|
-
content = text[:-len(newline)]
|
|
314
|
-
else:
|
|
315
|
-
content = text
|
|
316
|
-
lines = content.split(newline) if content else []
|
|
317
|
-
|
|
318
|
-
tables = find_tables(lines)
|
|
319
|
-
open_heading = args.open
|
|
320
|
-
closed_heading = args.closed
|
|
321
|
-
|
|
322
|
-
open_table = None
|
|
323
|
-
closed_table = None
|
|
324
|
-
for t in tables:
|
|
325
|
-
if t.heading == open_heading and open_table is None:
|
|
326
|
-
open_table = t
|
|
327
|
-
if t.heading == closed_heading and closed_table is None:
|
|
328
|
-
closed_table = t
|
|
329
|
-
|
|
330
|
-
if open_table is None:
|
|
331
|
-
print(f"caddis-rows: open table '{open_heading}' not found", file=sys.stderr)
|
|
332
|
-
return caddis_exit.BLOCKED
|
|
333
|
-
|
|
334
|
-
if closed_table is None:
|
|
335
|
-
print(f"caddis-rows: closed table '{closed_heading}' not found", file=sys.stderr)
|
|
336
|
-
return caddis_exit.BLOCKED
|
|
337
|
-
|
|
338
|
-
if open_table is closed_table:
|
|
339
|
-
print("caddis-rows: open table and closed table must be distinct", file=sys.stderr)
|
|
340
|
-
return caddis_exit.BLOCKED
|
|
341
|
-
|
|
342
|
-
open_col_map = {c.lower(): idx for idx, c in enumerate(open_table.columns)}
|
|
343
|
-
if "status" not in open_col_map:
|
|
344
|
-
print(f"caddis-rows: open table '{open_heading}' has no Status column", file=sys.stderr)
|
|
345
|
-
return caddis_exit.BLOCKED
|
|
346
|
-
|
|
347
|
-
status_idx = open_col_map["status"]
|
|
348
|
-
archivable_pattern = re.compile(r"^(done|not-needed)\b")
|
|
349
|
-
|
|
350
|
-
archivable_indices: list[int] = []
|
|
351
|
-
archived_ids: list[str] = []
|
|
352
|
-
|
|
353
|
-
for line_idx in open_table.data_line_indices:
|
|
354
|
-
cells = split_cells(lines[line_idx])
|
|
355
|
-
status_val = cells[status_idx].strip().lower() if len(cells) > status_idx else ""
|
|
356
|
-
if archivable_pattern.search(status_val):
|
|
357
|
-
archivable_indices.append(line_idx)
|
|
358
|
-
row_id = (
|
|
359
|
-
cells[open_table.id_col_idx]
|
|
360
|
-
if (open_table.id_col_idx is not None and len(cells) > open_table.id_col_idx)
|
|
361
|
-
else ""
|
|
362
|
-
)
|
|
363
|
-
archived_ids.append(row_id)
|
|
364
|
-
|
|
365
|
-
if not archivable_indices:
|
|
366
|
-
return caddis_exit.CLEAN
|
|
367
|
-
|
|
368
|
-
same_headers = (open_table.columns == closed_table.columns)
|
|
369
|
-
new_closed_lines: list[str] = []
|
|
370
|
-
|
|
371
|
-
if same_headers:
|
|
372
|
-
for idx in archivable_indices:
|
|
373
|
-
new_closed_lines.append(lines[idx])
|
|
374
|
-
else:
|
|
375
|
-
closed_col_map = {c.lower(): idx for idx, c in enumerate(closed_table.columns)}
|
|
376
|
-
dropped = [col for col in open_table.columns if col.lower() not in closed_col_map]
|
|
377
|
-
if dropped:
|
|
378
|
-
print(f"dropped columns: {', '.join(dropped)}")
|
|
379
|
-
|
|
380
|
-
for idx in archivable_indices:
|
|
381
|
-
cells = split_cells(lines[idx])
|
|
382
|
-
open_vals = {
|
|
383
|
-
col.lower(): (cells[i] if i < len(cells) else "")
|
|
384
|
-
for i, col in enumerate(open_table.columns)
|
|
385
|
-
}
|
|
386
|
-
row_cells = []
|
|
387
|
-
for col in closed_table.columns:
|
|
388
|
-
val = open_vals.get(col.lower())
|
|
389
|
-
if val is None or val == "":
|
|
390
|
-
val = "-"
|
|
391
|
-
row_cells.append(val)
|
|
392
|
-
new_closed_lines.append("| " + " | ".join(row_cells) + " |")
|
|
393
|
-
|
|
394
|
-
archivable_set = set(archivable_indices)
|
|
395
|
-
insert_after_idx = (
|
|
396
|
-
closed_table.data_line_indices[-1]
|
|
397
|
-
if closed_table.data_line_indices
|
|
398
|
-
else closed_table.separator_line_idx
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
result_lines: list[str] = []
|
|
402
|
-
for idx, line in enumerate(lines):
|
|
403
|
-
if idx not in archivable_set:
|
|
404
|
-
result_lines.append(line)
|
|
405
|
-
if idx == insert_after_idx:
|
|
406
|
-
result_lines.extend(new_closed_lines)
|
|
407
|
-
|
|
408
|
-
output_text = newline.join(result_lines)
|
|
409
|
-
if ends_with_newline and result_lines:
|
|
410
|
-
output_text += newline
|
|
411
|
-
_atomic_write_bytes(file_path, output_text.encode("utf-8"))
|
|
412
|
-
|
|
413
|
-
for rid in archived_ids:
|
|
414
|
-
print(f"archived {rid}")
|
|
415
|
-
|
|
416
|
-
return caddis_exit.CLEAN
|
|
417
|
-
|
|
418
|
-
|
|
419
29
|
def main(argv: list[str] | None = None) -> int:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
423
|
-
except Exception:
|
|
424
|
-
pass
|
|
425
|
-
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
426
|
-
try:
|
|
427
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
428
|
-
except Exception:
|
|
429
|
-
pass
|
|
430
|
-
|
|
431
|
-
if argv is None:
|
|
432
|
-
argv = sys.argv[1:]
|
|
433
|
-
|
|
434
|
-
parser = argparse.ArgumentParser(prog="caddis_rows", exit_on_error=False)
|
|
435
|
-
subparsers = parser.add_subparsers(dest="subcommand")
|
|
436
|
-
|
|
437
|
-
set_parser = subparsers.add_parser("set", exit_on_error=False)
|
|
438
|
-
set_parser.add_argument("--file", required=True)
|
|
439
|
-
set_parser.add_argument("--id")
|
|
440
|
-
set_parser.add_argument("--col", action="append", default=[])
|
|
441
|
-
set_parser.add_argument("--from", dest="from_file")
|
|
442
|
-
set_parser.add_argument("--table")
|
|
443
|
-
|
|
444
|
-
archive_parser = subparsers.add_parser("archive-done", exit_on_error=False)
|
|
445
|
-
archive_parser.add_argument("--file", required=True)
|
|
446
|
-
archive_parser.add_argument("--open", default="Open")
|
|
447
|
-
archive_parser.add_argument("--closed", default="Closed")
|
|
448
|
-
|
|
449
|
-
try:
|
|
450
|
-
args = parser.parse_args(argv)
|
|
451
|
-
except (argparse.ArgumentError, argparse.ArgumentTypeError) as exc:
|
|
452
|
-
print(f"caddis-rows: {exc}", file=sys.stderr)
|
|
453
|
-
return caddis_exit.ERROR
|
|
454
|
-
except SystemExit as exc:
|
|
455
|
-
code = exc.code if isinstance(exc.code, int) else 0
|
|
456
|
-
return caddis_exit.ERROR if code != 0 else 0
|
|
457
|
-
|
|
458
|
-
if not args.subcommand:
|
|
459
|
-
print("caddis-rows: subcommand required (set, archive-done)", file=sys.stderr)
|
|
460
|
-
return caddis_exit.ERROR
|
|
461
|
-
|
|
462
|
-
try:
|
|
463
|
-
if args.subcommand == "set":
|
|
464
|
-
return handle_set(args)
|
|
465
|
-
elif args.subcommand == "archive-done":
|
|
466
|
-
return handle_archive_done(args)
|
|
467
|
-
else:
|
|
468
|
-
print(f"caddis-rows: unknown subcommand '{args.subcommand}'", file=sys.stderr)
|
|
469
|
-
return caddis_exit.ERROR
|
|
470
|
-
except (OSError, UnicodeError, ValueError) as exc:
|
|
471
|
-
print(f"caddis-rows: {exc}", file=sys.stderr)
|
|
472
|
-
return caddis_exit.BLOCKED
|
|
30
|
+
print(RETIRED)
|
|
31
|
+
return 0
|
|
473
32
|
|
|
474
33
|
|
|
475
34
|
if __name__ == "__main__":
|
|
476
|
-
raise SystemExit(main())
|
|
35
|
+
raise SystemExit(main(sys.argv[1:]))
|