@caddis/cli 0.4.69 → 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_roadmap_run.py +5 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +10 -451
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +6 -7
- 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/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/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}")
|
|
@@ -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:]))
|
|
@@ -89,7 +89,7 @@ except ModuleNotFoundError as exc:
|
|
|
89
89
|
raise
|
|
90
90
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
91
91
|
import caddis_todo
|
|
92
|
-
# 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.
|
|
93
93
|
from caddis_file_lock import atomic_write # noqa: E402
|
|
94
94
|
|
|
95
95
|
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
@@ -160,9 +160,8 @@ def _parse_frontmatter(text: str) -> dict:
|
|
|
160
160
|
def tracker_complete(text: str) -> bool:
|
|
161
161
|
"""True only when the `## Tracker` heading line has phase rows, all unique, all done.
|
|
162
162
|
|
|
163
|
-
The table is read by caddis_todo.tracker_table,
|
|
164
|
-
|
|
165
|
-
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
|
|
166
165
|
hides it; wrongly keeping a finished one costs a look.
|
|
167
166
|
"""
|
|
168
167
|
table = caddis_todo.tracker_table(text)
|
|
@@ -476,9 +475,9 @@ class Resolution:
|
|
|
476
475
|
def _parked_target(root: Path, name: str) -> tuple[Path | None, str | None]:
|
|
477
476
|
"""Where a trailer value points: (open item, None), (None, None) when it is already in done/,
|
|
478
477
|
or (None, reason) when it is refused or missing. Only a top-level item of this repo's
|
|
479
|
-
`.caddis/parking-lot/` is ever a target, named bare, as `parking-lot/<file>` (what
|
|
480
|
-
`caddis_todo.py done`
|
|
481
|
-
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."""
|
|
482
481
|
lot = root / ".caddis" / PARKING_LOT_DIR
|
|
483
482
|
posix = name.replace("\\", "/")
|
|
484
483
|
if not posix:
|
|
@@ -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
|
|
@@ -36,7 +36,6 @@ them into its commit, so check first, before Step 1.
|
|
|
36
36
|
- Step 3, point 4: make the plan's Tracker edit in `<worktree>/.caddis/plans/<feature>.md`, and write
|
|
37
37
|
any new parking-lot item under `<worktree>/.caddis/parking-lot/`, not in this checkout.
|
|
38
38
|
- Step 4: skip `caddis_tidy.py --apply` (report `skipped: shared checkout`).
|
|
39
|
-
- Step 6: retired; nothing to skip (row 8 reports `skipped: retired (see the 3-store model)`).
|
|
40
39
|
- Step 6b: skip `session_state.py prune` (report `skipped: shared checkout`).
|
|
41
40
|
- Step 6c: run `--reindex` in the worktree (it edits the tracked `DOC-MAP.md`).
|
|
42
41
|
- Step 6d: runs as written. It never writes a file in this checkout; it only removes worktrees and
|
|
@@ -53,27 +52,22 @@ them into its commit, so check first, before Step 1.
|
|
|
53
52
|
|
|
54
53
|
## Checklist
|
|
55
54
|
The handoff is a fixed checklist. Every row ends as `done`, `skipped: <reason>` or `failed: <error>`, and
|
|
56
|
-
Step 8 prints all
|
|
57
|
-
Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers do not
|
|
58
|
-
move for a fleet that mixes caddis versions, and each always reports
|
|
59
|
-
`skipped: retired (see the 3-store model)`. A later release removes them with their scripts.
|
|
55
|
+
Step 8 prints all 12 in this order. Never drop a row from the report, even when its step did not apply.
|
|
60
56
|
|
|
61
57
|
| # | Row | What it is | Shared checkout (Step 0) |
|
|
62
58
|
|---|---|---|---|
|
|
63
59
|
| 1 | `knowledge-transfer` | Step 1 | edits only in the Step 0 worktree |
|
|
64
60
|
| 2 | `plan-tracker` | the plan's Tracker row (Rules) | edit in the Step 0 worktree |
|
|
65
|
-
| 3 | `
|
|
66
|
-
| 4 | `
|
|
67
|
-
| 5 | `
|
|
68
|
-
| 6 | `
|
|
69
|
-
| 7 | `
|
|
70
|
-
| 8 | `
|
|
71
|
-
| 9 | `
|
|
72
|
-
| 10 | `
|
|
73
|
-
| 11 | `
|
|
74
|
-
| 12 | `
|
|
75
|
-
| 13 | `handover-check` | Step 5 | runs |
|
|
76
|
-
| 14 | `own-cleanup` | Step 6d: `caddis_created.py cleanup --apply` removes this session's merged, clean worktrees and scratch folders; lists the rest with a reason | runs (only this session's recorded worktrees and scratch) |
|
|
61
|
+
| 3 | `relay` | Step 3: the relay, written from scratch with current state only | the relay is local; runs |
|
|
62
|
+
| 4 | `tasks-file` | Step 3c | runs |
|
|
63
|
+
| 5 | `kb-index` | Step 6c: refresh the KB index | in the Step 0 worktree |
|
|
64
|
+
| 6 | `violations-report` | Step 6c: report standing violations, each with a one-line fix command | runs (report only) |
|
|
65
|
+
| 7 | `usage-record` | Step 6b: `session_state.py usage` | runs |
|
|
66
|
+
| 8 | `tidy-apply` | Step 4: `caddis_tidy.py --apply` | `skipped: shared checkout` |
|
|
67
|
+
| 9 | `state-prune` | Step 6b: `session_state.py prune` | `skipped: shared checkout` |
|
|
68
|
+
| 10 | `prune-list` | Step 6b: `caddis_tidy.py --prune --temp`, list only | runs (lists, never applies) |
|
|
69
|
+
| 11 | `handover-check` | Step 5 | runs |
|
|
70
|
+
| 12 | `own-cleanup` | Step 6d: `caddis_created.py cleanup --apply` removes this session's merged, clean worktrees and scratch folders; lists the rest with a reason | runs (only this session's recorded worktrees and scratch) |
|
|
77
71
|
|
|
78
72
|
`tidy-apply` and `state-prune` run only on `SHARED: no`. Handoff never runs
|
|
79
73
|
`caddis_tidy.py --prune --apply` on its own, in any checkout (nor `--prune --temp --apply`); only the user can ask for it (Step 6b). In a
|
|
@@ -172,7 +166,6 @@ history, and the relay has a 4,000-character budget (`caddis_budget.py`).
|
|
|
172
166
|
> checkout (Step 0) write it in the Step 0 worktree.
|
|
173
167
|
> - **The order of work:** the plan Tracker and the roadmap (below). Set the Tracker row of each phase this
|
|
174
168
|
> session finished (Rules).
|
|
175
|
-
> Row 3 (`open-work-row`) is retired: report it exactly as `skipped: retired (see the 3-store model)`.
|
|
176
169
|
> The `.caddis/open-work.md` and `.caddis/todo-list/` stores are retired. Never write them. A repo that
|
|
177
170
|
> still holds an old file keeps it as its own document; caddis no longer reads or writes it.
|
|
178
171
|
> **Order of work:** when the repo has a roadmap (`caddis_roadmap.py list` prints a slug), the
|
|
@@ -294,11 +287,6 @@ incoming session looking for a prompt that was never written. Fix what it lists,
|
|
|
294
287
|
It also notes any generated artefact committed before its generator last changed. That is advisory,
|
|
295
288
|
not blocking — but do not quote a report the note names without rebuilding it first.
|
|
296
289
|
|
|
297
|
-
## Step 6 — retired (task reconcile)
|
|
298
|
-
This step is retired. It used to reconcile the `todo-list/` store, which is retired too (the 3-store model),
|
|
299
|
-
so the step does nothing: do not run any script for it. Report row 8 (`todo-reconcile`) as
|
|
300
|
-
`skipped: retired (see the 3-store model)`. The script stays in place for older installs only.
|
|
301
|
-
|
|
302
290
|
## Step 6b — record usage and tidy session state
|
|
303
291
|
The Stop hook only writes the session-state file. These two used to run on every turn; they run
|
|
304
292
|
here, once:
|
|
@@ -309,7 +297,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/session_state.py" prune
|
|
|
309
297
|
`usage` finds this session's transcript by its id and appends one token-and-skill record to
|
|
310
298
|
`.caddis/usage-log.jsonl`, which `/usage-review` reads. Pass `--transcript <path>` if you know the
|
|
311
299
|
path and it is not under the Claude config folder. `prune` keeps the newest 8 files in
|
|
312
|
-
`.caddis/session-state/`; run it after Step
|
|
300
|
+
`.caddis/session-state/`; run it after Step 3c, which reads this session's file. Skip `prune` in a
|
|
313
301
|
shared checkout (Step 0). Include the
|
|
314
302
|
`[USAGE]` line in your output. Both always exit 0; if the script is missing, skip this step.
|
|
315
303
|
|
|
@@ -328,12 +316,12 @@ yourself**; deleting is the user's choice. If they ask, run `--prune --temp --ap
|
|
|
328
316
|
Both scripts fall back to `claude-harness/scripts/check_doc_coverage.py` and `scripts/caddis_tidy.py` in a
|
|
329
317
|
source checkout; a script that is missing is `skipped: <script> not installed`.
|
|
330
318
|
|
|
331
|
-
Checklist row
|
|
319
|
+
Checklist row 5, `kb-index` (in a shared checkout, run it in the Step 0 worktree, before the Step 0
|
|
332
320
|
`finish`; it edits `DOC-MAP.md`):
|
|
333
321
|
```
|
|
334
322
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/check_doc_coverage.py" --reindex
|
|
335
323
|
```
|
|
336
|
-
Checklist row
|
|
324
|
+
Checklist row 6, `violations-report` (all three only report; never fix files here):
|
|
337
325
|
```
|
|
338
326
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --check
|
|
339
327
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/check_doc_coverage.py"
|
|
@@ -353,7 +341,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_roadmap.py" render --all
|
|
|
353
341
|
```
|
|
354
342
|
|
|
355
343
|
## Step 6d — remove this session's own worktrees and scratch folders
|
|
356
|
-
Checklist row
|
|
344
|
+
Checklist row 12, `own-cleanup`. It runs in every checkout, a shared one too, after the Step 0 `finish`
|
|
357
345
|
(falls back to `scripts/caddis_created.py` in a source checkout; if the script is missing, or exits 2 because
|
|
358
346
|
it has no `cleanup`, the row is `skipped: not installed`):
|
|
359
347
|
```
|
|
@@ -392,18 +380,16 @@ Print this block, every row filled with `done`, `skipped: <reason>` or `failed:
|
|
|
392
380
|
=== HANDOFF CHECKLIST ===
|
|
393
381
|
1 knowledge-transfer done | skipped: <reason> | failed: <error>
|
|
394
382
|
2 plan-tracker <same three forms>
|
|
395
|
-
3
|
|
396
|
-
4
|
|
397
|
-
5
|
|
398
|
-
6
|
|
399
|
-
7
|
|
400
|
-
8
|
|
401
|
-
9
|
|
402
|
-
10
|
|
403
|
-
11
|
|
404
|
-
12
|
|
405
|
-
13 handover-check <same three forms>
|
|
406
|
-
14 own-cleanup <same three forms>
|
|
383
|
+
3 relay <same three forms>
|
|
384
|
+
4 tasks-file <same three forms>
|
|
385
|
+
5 kb-index <same three forms>
|
|
386
|
+
6 violations-report <same three forms>
|
|
387
|
+
7 usage-record <same three forms>
|
|
388
|
+
8 tidy-apply <same three forms>
|
|
389
|
+
9 state-prune <same three forms>
|
|
390
|
+
10 prune-list <same three forms>
|
|
391
|
+
11 handover-check <same three forms>
|
|
392
|
+
12 own-cleanup <same three forms>
|
|
407
393
|
Shared checkout: yes|no <reason>
|
|
408
394
|
```
|
|
409
395
|
|
|
@@ -351,11 +351,10 @@ def main() -> None:
|
|
|
351
351
|
conversation = str(data.get("conversationId") or "").strip()
|
|
352
352
|
|
|
353
353
|
blocks = []
|
|
354
|
-
open_work_blocks = []
|
|
355
354
|
for root in roots:
|
|
356
355
|
if conversation and _already_injected(root, conversation):
|
|
357
356
|
continue
|
|
358
|
-
relay,
|
|
357
|
+
relay, _ws = resolve_relay(root, conversation)
|
|
359
358
|
text = ""
|
|
360
359
|
if relay:
|
|
361
360
|
try:
|
|
@@ -370,41 +369,7 @@ def main() -> None:
|
|
|
370
369
|
blocks.append("relay for " + os.path.basename(root.rstrip("/\\")) + ":")
|
|
371
370
|
blocks.append(truncate_relay(text))
|
|
372
371
|
|
|
373
|
-
|
|
374
|
-
# relay: to-dos and plan phases exist before the first handoff writes one.
|
|
375
|
-
shown_open_work = False
|
|
376
|
-
try:
|
|
377
|
-
from pathlib import Path as _Path
|
|
378
|
-
# agy plugin: warm_start_agy.py sits at the plugin root, scripts in <plugin>/scripts.
|
|
379
|
-
# Source tree: claude-harness/agy -> <repo>/scripts, where caddis_todo.py is authored.
|
|
380
|
-
_here = os.path.dirname(os.path.abspath(__file__))
|
|
381
|
-
for _sc in (os.path.join(os.path.dirname(os.path.dirname(_here)), "scripts"),
|
|
382
|
-
os.path.join(_here, "scripts")):
|
|
383
|
-
if os.path.isdir(_sc) and _sc not in sys.path:
|
|
384
|
-
sys.path.insert(0, _sc)
|
|
385
|
-
import caddis_todo
|
|
386
|
-
open_items = caddis_todo.open_work(_Path(root), matched_ws)
|
|
387
|
-
if open_items:
|
|
388
|
-
# Same budget as the Claude Code hook: next 5 phases, 5 ad-hoc items.
|
|
389
|
-
phases = [r for r in open_items if r.get("kind") == "phase"][:5]
|
|
390
|
-
todos = [r for r in open_items if r.get("kind") != "phase"][:5]
|
|
391
|
-
shown = phases + todos
|
|
392
|
-
ow_lines = ["## Open work"]
|
|
393
|
-
if len(open_items) > len(shown):
|
|
394
|
-
ow_lines.append(f"(+{len(open_items) - len(shown)} more open items — run /catchup to see them.)")
|
|
395
|
-
for row in shown:
|
|
396
|
-
ow_lines.append(f"- {row['id']} [{row['kind']}] {' '.join(str(row['title']).split())[:100]}")
|
|
397
|
-
if len(roots) > 1:
|
|
398
|
-
open_work_blocks.append("open work for " + os.path.basename(root.rstrip("/\\")) + ":\n" + "\n".join(ow_lines))
|
|
399
|
-
else:
|
|
400
|
-
open_work_blocks.append("\n".join(ow_lines))
|
|
401
|
-
shown_open_work = True
|
|
402
|
-
except Exception as _exc:
|
|
403
|
-
_hook_note("agy open work injection", _exc, root)
|
|
404
|
-
|
|
405
|
-
# A relay that exists but failed to read must not be marked injected just because
|
|
406
|
-
# open work was shown: the next invocation retries the relay.
|
|
407
|
-
if conversation and (text or (not relay and shown_open_work)):
|
|
372
|
+
if conversation and text:
|
|
408
373
|
_mark_injected(root, conversation)
|
|
409
374
|
|
|
410
375
|
# agy has no SessionStart event, so this first invocation is also where the freshness
|
|
@@ -414,14 +379,12 @@ def main() -> None:
|
|
|
414
379
|
for root in roots:
|
|
415
380
|
fresh.extend(freshness_lines(root))
|
|
416
381
|
|
|
417
|
-
if not blocks and not fresh
|
|
382
|
+
if not blocks and not fresh:
|
|
418
383
|
return
|
|
419
384
|
parts = []
|
|
420
385
|
if blocks:
|
|
421
386
|
parts.append(RELAY_FRAME_HEADER + "\n" + "\n\n".join(blocks)
|
|
422
387
|
+ "\n\n" + RELAY_FRAME_FOOTER)
|
|
423
|
-
if open_work_blocks:
|
|
424
|
-
parts.append("\n\n".join(open_work_blocks))
|
|
425
388
|
if fresh:
|
|
426
389
|
parts.append("\n".join(fresh))
|
|
427
390
|
payload = json.dumps({"injectSteps": [{"ephemeralMessage": "\n\n".join(parts)}]})
|
|
@@ -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
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED