@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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.154",
3
+ "version": "1.3.156",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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) -> list[dict]:
281
- """Top-level plans in `status: doing` or `status: current` whose last git commit is older
282
- than `days` days."""
277
+ def stale_plans(root: Path, days: int = 14,
278
+ statuses: tuple[str, ...] = ("doing", "current")) -> list[dict]:
279
+ """Top-level plans whose `status:` is one of `statuses` (default `doing` or `current`) and whose
280
+ last git commit is older than `days` days. `caddis_tidy.py --check` calls it with `current` only."""
283
281
  d = root / ART / "plans"
284
282
  if not d.is_dir() or not (root / ".git").exists():
285
283
  return []
@@ -291,7 +289,7 @@ def stale_plans(root: Path, days: int = 14) -> list[dict]:
291
289
  continue
292
290
  fm = frontmatter(f)
293
291
  status = (fm.get("status") or "").strip()
294
- if status.lower() not in ("doing", "current"):
292
+ if status.lower() not in statuses:
295
293
  continue
296
294
  rel = f"{ART}/plans/{f.name}"
297
295
  ts = _run(["git", "log", "-1", "--format=%ct", "--", rel], root)
@@ -464,6 +462,10 @@ def render_md(inv: Inventory) -> str:
464
462
 
465
463
 
466
464
  def main(argv: list[str] | None = None) -> int:
465
+ # Only when run as a command: importing this module (caddis_tidy and caddis_gate do) must not
466
+ # change the importer's stdout encoding.
467
+ if hasattr(sys.stdout, "reconfigure"):
468
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
467
469
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
468
470
  ap.add_argument("--repo-root", default=".")
469
471
  ap.add_argument("--format", choices=("md", "json"), default="md")
@@ -999,7 +999,12 @@ def _prune_stale_review_worktrees(root: Path) -> None:
999
999
 
1000
1000
  def _add_worktree(root: Path, head: str, phase: int | str, run: int) -> Path:
1001
1001
  path = Path(tempfile.gettempdir()) / f"caddis-review-{phase}-{run}-{uuid.uuid4().hex[:8]}"
1002
- _git_out(root, "worktree", "add", "--detach", str(path), head)
1002
+ try:
1003
+ _git_out(root, "worktree", "add", "--detach", str(path), head)
1004
+ except BaseException:
1005
+ # A failed `worktree add` can leave the half-made folder: the caller has no path to remove (tidy v2 Phase 5).
1006
+ caddis_worktree_safety.remove_tree(path)
1007
+ raise
1003
1008
  return path
1004
1009
 
1005
1010
 
@@ -1081,8 +1081,10 @@ def merged_checkout(root: Path, branch: str):
1081
1081
  _git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
1082
1082
  yield worktree
1083
1083
  finally:
1084
- caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1085
- caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1084
+ try:
1085
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1086
+ finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
1087
+ caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1086
1088
 
1087
1089
 
1088
1090
  def _kill_tree(proc: subprocess.Popen) -> None:
@@ -1849,9 +1851,11 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1849
1851
  else:
1850
1852
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1851
1853
  finally:
1852
- caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1853
- _git(root, "branch", "-D", branch, check=False)
1854
- caddis_worktree_safety.remove_tree(tmp)
1854
+ try:
1855
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1856
+ _git(root, "branch", "-D", branch, check=False)
1857
+ finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
1858
+ caddis_worktree_safety.remove_tree(tmp)
1855
1859
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
1856
1860
  "pr": pr, "merge_exit": merge_exit if merge_exit is not None
1857
1861
  else (rc if pr is not None else None)})
@@ -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 or in `.caddis/open-work.md` is progress; the relay is `.caddis/relay/<slug>.md`; the log and STOP file are in
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, repo / ".caddis" / "open-work.md"),
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
- # -- open work: closed rows -------------------------------------------------------------------------------------------
339
+ # -- closed rows ------------------------------------------------------------------------------------------------------
341
340
 
342
341
 
343
- def closed_rows(open_work: Path) -> int:
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 = open_work.read_text(encoding="utf-8").splitlines()
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
- """Set open-work table cells by row id and column name; archive done rows."""
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
- @dataclass
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
- if sys.stdout and hasattr(sys.stdout, "reconfigure"):
421
- try:
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:]))