@caddis/cli 0.4.52 → 0.4.53
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/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -32,13 +32,25 @@ DESIGN (locked, see the plan doc — do not re-litigate here)
|
|
|
32
32
|
whose document is gone are only counted, never deleted. Only a file git both ignores and does not track qualifies. Relays,
|
|
33
33
|
to-dos, symlinks and anything outside `.caddis/` are never touched; lane worktrees older than
|
|
34
34
|
7 days are only reported. It lists by default; `--prune --apply` deletes.
|
|
35
|
+
- Tidy is part of finishing (caddis-next Phase 13): `--check` also fails on a plan whose Tracker
|
|
36
|
+
is all done but whose status is still active, because the finishing PR should have closed it.
|
|
37
|
+
`--check --commit-range` checks only the files `git diff` says the range changed, so problems
|
|
38
|
+
already in a repo never block an unrelated PR. It first refuses when any of those files has an
|
|
39
|
+
uncommitted change (the check would read a file that is not what the PR ships), and a range git
|
|
40
|
+
cannot read prints only that error, never an unfiltered list.
|
|
41
|
+
- `--only <plan> [<plan> ...]` limits a run to those top-level plans: nothing else is moved or
|
|
42
|
+
stamped. `/implement` closes the plan it finished with it, so another session's finished work
|
|
43
|
+
is never swept into this PR.
|
|
35
44
|
|
|
36
45
|
Usage:
|
|
37
46
|
python scripts/caddis_tidy.py # report only (default = dry-run)
|
|
38
47
|
python scripts/caddis_tidy.py --apply # actually move terminal-status artifacts
|
|
48
|
+
python scripts/caddis_tidy.py --apply --only .caddis/plans/<plan>.md # close this plan only
|
|
39
49
|
python scripts/caddis_tidy.py --check # conformance check; exit 1 on a violation
|
|
40
50
|
python scripts/caddis_tidy.py --repo-root <p> # scan a different repo (default: cwd)
|
|
41
51
|
python scripts/caddis_tidy.py --commit-range origin/main..HEAD # also read parking trailers
|
|
52
|
+
python scripts/caddis_tidy.py --check --commit-range origin/main..HEAD # check only the files
|
|
53
|
+
# this range changed (what /ship-pr runs)
|
|
42
54
|
python scripts/caddis_tidy.py --prune # list old local state that could be deleted
|
|
43
55
|
python scripts/caddis_tidy.py --prune --apply # delete it (the user's choice, never automatic)
|
|
44
56
|
"""
|
|
@@ -83,17 +95,14 @@ TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
|
|
|
83
95
|
LEGACY_FRONTMATTER_EXEMPT = {
|
|
84
96
|
"prompts/caddis-rename-and-publish.md",
|
|
85
97
|
"prompts/db-diagram-fix-er-arrows-theme-formatting.md",
|
|
86
|
-
"prompts/docket-phase-2c-ux-correctness.md",
|
|
87
|
-
"prompts/docket-phase-3-f12-worktree.md",
|
|
88
|
-
"prompts/docket-reaudit-f12-and-pipeline-runner.md",
|
|
89
98
|
"prompts/driver-remaining-toolchain-work.md",
|
|
90
99
|
"prompts/fable-inspect-claudster.md",
|
|
91
|
-
"prompts/fable-inspect-docket.md",
|
|
92
|
-
"prompts/fable-verify-docket-reaudit.md",
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
# A Markdown table rule such as `|---|:---:|`.
|
|
96
103
|
_TABLE_RULE_RE = re.compile(r"^\|(\s*:?-+:?\s*\|)+\s*$")
|
|
104
|
+
# A plan's phase heading, `### Phase 3 ...` or `### Phase 3b ...`; group 1 is the phase id.
|
|
105
|
+
_PHASE_HEADING_RE = re.compile(r"^###\s+Phase\s+(\d+[A-Za-z]?)(?![A-Za-z0-9])", re.M)
|
|
97
106
|
# Commit trailers that close or annotate a parking-lot item. Keys compare case-insensitively,
|
|
98
107
|
# as git's own trailer keys do.
|
|
99
108
|
_PARKED_TRAILERS = {"resolves-parked": "resolves", "partly-resolves-parked": "partly"}
|
|
@@ -105,7 +114,7 @@ _KINDS = ("plans", "prompts", "parking-lot")
|
|
|
105
114
|
# ── parking-lot: the ONE register of future work ────────────────────────────────────────────
|
|
106
115
|
# Before this landed, future work lived in nine places at once (measured 2026-08-14 in this repo):
|
|
107
116
|
# individual parking-lot files, a 90 KB `future-work-register.md`, parked plans, relay.md's
|
|
108
|
-
# "things owed", KB notes, `.caddis/plans/backlog/`, the comms register,
|
|
117
|
+
# "things owed", KB notes, `.caddis/plans/backlog/`, the comms register, an external task board, and the Hub's
|
|
109
118
|
# carried-open lists. Nothing was wrong with any single one; the DEFECT was that a reader had to
|
|
110
119
|
# know all nine to answer "what is left to do?". Nobody did, so items were re-raised and dropped.
|
|
111
120
|
#
|
|
@@ -165,9 +174,75 @@ def tracker_complete(text: str) -> bool:
|
|
|
165
174
|
seen.add(number)
|
|
166
175
|
if not caddis_todo._is_done(cells[status_at]):
|
|
167
176
|
return False
|
|
177
|
+
# A `### Phase N` heading with no row for N is a phase the Tracker forgot, not a finished one.
|
|
178
|
+
# Ids compare as written: a `3b` heading needs a `3b` row, and a row is a whole number, so a
|
|
179
|
+
# plan with a lettered phase is never closed by tidy (a look costs less than a hidden plan).
|
|
180
|
+
rows = {str(n) for n in seen}
|
|
181
|
+
if any(m.group(1).lower() not in rows for m in _PHASE_HEADING_RE.finditer(text)):
|
|
182
|
+
return False
|
|
168
183
|
return bool(seen)
|
|
169
184
|
|
|
170
185
|
|
|
186
|
+
# A cell that STARTS with `dropped:` and a reason excuses an open row. `not dropped: x` does not.
|
|
187
|
+
_DROPPED_CELL_RE = re.compile(r"^dropped:\s*\S", re.IGNORECASE)
|
|
188
|
+
_PARKING_REF_RE = re.compile(r"\.caddis/parking-lot/(?:done/)?([A-Za-z0-9][A-Za-z0-9._-]*\.md)")
|
|
189
|
+
# Same phase-id rule as caddis_roadmap: `2`, `2a`, `Phase 2`, `P2`.
|
|
190
|
+
_ROW_PHASE_RE = re.compile(r"^\|\s*(?:phase\s+|p)?(\d+[a-z]?)\b", re.IGNORECASE)
|
|
191
|
+
_UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _row_cells(line: str) -> list[str]:
|
|
195
|
+
"""Stripped cells of one table line, split on unescaped pipes only (as caddis_rows.split_cells)."""
|
|
196
|
+
parts = _UNESCAPED_PIPE_RE.split(line.strip())
|
|
197
|
+
if parts and parts[0] == "":
|
|
198
|
+
parts.pop(0)
|
|
199
|
+
if parts and parts[-1] == "":
|
|
200
|
+
parts.pop(-1)
|
|
201
|
+
return [part.strip() for part in parts]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def open_tracker_rows(path: Path) -> list[tuple[str, str]]:
|
|
205
|
+
"""Return `(phase_id, row_line)` for every open phase row in `path`'s `## Tracker` table.
|
|
206
|
+
|
|
207
|
+
No Tracker, or a Tracker with no Status column, gives [].
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
211
|
+
except OSError:
|
|
212
|
+
return []
|
|
213
|
+
table = caddis_todo.tracker_table(text)
|
|
214
|
+
if table is None:
|
|
215
|
+
return []
|
|
216
|
+
columns, lines = table
|
|
217
|
+
if "status" not in columns:
|
|
218
|
+
return []
|
|
219
|
+
status_at = columns.index("status")
|
|
220
|
+
out: list[tuple[str, str]] = []
|
|
221
|
+
for line in lines:
|
|
222
|
+
match = _ROW_PHASE_RE.match(line.strip())
|
|
223
|
+
if match is None or _TABLE_RULE_RE.match(line.strip()):
|
|
224
|
+
continue
|
|
225
|
+
cells = _row_cells(line)
|
|
226
|
+
if len(cells) <= status_at:
|
|
227
|
+
continue
|
|
228
|
+
if not caddis_todo._is_done(cells[status_at]):
|
|
229
|
+
out.append((match.group(1), line))
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _row_excused(repo_root: Path, row_line: str) -> bool:
|
|
234
|
+
"""True when one cell starts with `dropped: <reason>`, or a cell names a parking-lot file that
|
|
235
|
+
exists (open, or already moved to `.caddis/parking-lot/done/`)."""
|
|
236
|
+
lot = repo_root / ".caddis" / PARKING_LOT_DIR
|
|
237
|
+
for cell in _row_cells(row_line):
|
|
238
|
+
if _DROPPED_CELL_RE.match(cell):
|
|
239
|
+
return True
|
|
240
|
+
for name in _PARKING_REF_RE.findall(cell):
|
|
241
|
+
if (lot / name).is_file() or (lot / "done" / name).is_file():
|
|
242
|
+
return True
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
171
246
|
def _set_header(text: str, fields: dict[str, str]) -> str | None:
|
|
172
247
|
"""`text` with these top-level header keys set, every other byte kept. None when there is no
|
|
173
248
|
header the shared reader accepts, or when the reader would not read the new values back."""
|
|
@@ -276,12 +351,35 @@ class Report:
|
|
|
276
351
|
# useless to the agent that has to fix it - it needs to know WHICH rule and WHAT the legal
|
|
277
352
|
# values are, in the failure text, without opening another document.
|
|
278
353
|
violations: list[tuple[Path, str]] = field(default_factory=list)
|
|
354
|
+
# --check only: plans whose Tracker is all done while `status:` is still active, with that
|
|
355
|
+
# status. The finishing PR should have run --apply; these fail --check.
|
|
356
|
+
tracker_done_open: list[tuple[Path, str]] = field(default_factory=list)
|
|
357
|
+
# --commit-range only: repo-relative posix paths the range changed.
|
|
358
|
+
in_range: set[str] | None = None
|
|
359
|
+
# --only only: named plans that did not move, with the reason.
|
|
360
|
+
not_moved: list[tuple[Path, str]] = field(default_factory=list)
|
|
361
|
+
# --apply only: files left alone because git shows uncommitted changes in them (another
|
|
362
|
+
# session's edit in a shared checkout). Neither moved nor rewritten; not an error.
|
|
363
|
+
dirty_skipped: list[Path] = field(default_factory=list)
|
|
364
|
+
# --apply only: git status could not be read in a git repo, so nothing was moved or rewritten.
|
|
365
|
+
dirty_check_failed: str | None = None
|
|
366
|
+
# Terminal plans (`done`/`shipped`/`implemented`) held at the top level because `## Tracker`
|
|
367
|
+
# still has unexcused open rows: (plan_path, [phase_ids]).
|
|
368
|
+
open_rows_held: list[tuple[Path, list[str]]] = field(default_factory=list)
|
|
369
|
+
# True when the run stopped before reading any artifact (an unreadable range, dirty files in
|
|
370
|
+
# the range, a bad --only path). Only `errors` means anything then.
|
|
371
|
+
refused: bool = False
|
|
279
372
|
|
|
280
373
|
@property
|
|
281
374
|
def blocked(self) -> bool:
|
|
282
375
|
"""True when --apply must not (or did not) finish: a collision or an error."""
|
|
283
376
|
return bool(self.collisions or self.errors)
|
|
284
377
|
|
|
378
|
+
def refuse(self, message: str) -> "Report":
|
|
379
|
+
self.errors.append(message)
|
|
380
|
+
self.refused = True
|
|
381
|
+
return self
|
|
382
|
+
|
|
285
383
|
def check_violations(self) -> int:
|
|
286
384
|
return len(self.check_flagged)
|
|
287
385
|
|
|
@@ -317,6 +415,29 @@ def _collides(src: Path) -> bool:
|
|
|
317
415
|
return (done_dir.exists() and not done_dir.is_dir()) or dst.exists() or dst.is_symlink()
|
|
318
416
|
|
|
319
417
|
|
|
418
|
+
def _dirty_paths(repo_root: Path) -> set[str] | None:
|
|
419
|
+
"""Repo-relative posix paths with uncommitted changes to tracked files (modified, staged, renamed,
|
|
420
|
+
deleted), from `git status --porcelain -z`. Untracked files are not listed: tidy moves them with a
|
|
421
|
+
plain rename that stages nothing. None when git status cannot be read."""
|
|
422
|
+
result = _git_run(repo_root, "status", "--porcelain", "-z", "--untracked-files=no")
|
|
423
|
+
if result is None or result.returncode != 0:
|
|
424
|
+
return None
|
|
425
|
+
dirty: set[str] = set()
|
|
426
|
+
entries = result.stdout.decode("utf-8", "replace").split("\0")
|
|
427
|
+
i = 0
|
|
428
|
+
while i < len(entries):
|
|
429
|
+
entry = entries[i]
|
|
430
|
+
i += 1
|
|
431
|
+
if len(entry) < 4:
|
|
432
|
+
continue
|
|
433
|
+
dirty.add(entry[3:])
|
|
434
|
+
if entry[0] in "RC" or entry[1] in "RC": # a rename or copy names its source in the next field
|
|
435
|
+
if i < len(entries) and entries[i]:
|
|
436
|
+
dirty.add(entries[i])
|
|
437
|
+
i += 1
|
|
438
|
+
return dirty
|
|
439
|
+
|
|
440
|
+
|
|
320
441
|
def _move_one(repo_root: Path, src: Path, report: Report) -> None:
|
|
321
442
|
"""One named `git mv` into the sibling `done/`; a plain rename when git cannot (no repo, or
|
|
322
443
|
an untracked file). Only called once every destination is known to be free."""
|
|
@@ -382,10 +503,7 @@ def parked_resolutions(root: Path, commit_range: str) -> list[Resolution]:
|
|
|
382
503
|
"""Every `Resolves-Parked:` / `Partly-Resolves-Parked:` trailer in `commit_range`, oldest
|
|
383
504
|
commit first. Reads git only; changes nothing. Raises ValueError when git cannot read the
|
|
384
505
|
range, so the caller can refuse the whole run instead of acting on half an answer."""
|
|
385
|
-
|
|
386
|
-
# commits, neither of which is the change being tidied.
|
|
387
|
-
if commit_range.startswith("-") or commit_range.count("..") != 1 or "..." in commit_range:
|
|
388
|
-
raise ValueError(f"not a commit range (use BASE..HEAD): {commit_range!r}")
|
|
506
|
+
_check_range_ok(commit_range)
|
|
389
507
|
try:
|
|
390
508
|
done = subprocess.run(
|
|
391
509
|
["git", "log", "--reverse", "--date=short", f"--abbrev={_SHA_ABBREV}",
|
|
@@ -468,6 +586,101 @@ def _plan_parked(root: Path, commit_range: str, report: Report,
|
|
|
468
586
|
report.noted.append(res.path)
|
|
469
587
|
|
|
470
588
|
|
|
589
|
+
def _check_range_ok(commit_range: str) -> None:
|
|
590
|
+
"""`A..B` only: a bare ref would read the whole history, and `A...B` adds the base's own
|
|
591
|
+
commits, neither of which is the change being tidied. Raises ValueError otherwise."""
|
|
592
|
+
if commit_range.startswith("-") or commit_range.count("..") != 1 or "..." in commit_range:
|
|
593
|
+
raise ValueError(f"not a commit range (use BASE..HEAD): {commit_range!r}")
|
|
594
|
+
base, head = commit_range.split("..")
|
|
595
|
+
if not base.strip() or not head.strip():
|
|
596
|
+
# git reads an empty side as HEAD, so `..HEAD` is an empty range that passes every check.
|
|
597
|
+
raise ValueError("commit range has an empty side (name both, e.g. origin/main..HEAD): "
|
|
598
|
+
f"{commit_range!r}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def changed_paths(root: Path, commit_range: str) -> set[str]:
|
|
602
|
+
"""Repo-root-relative posix paths the branch `B` changed since it left `A`, for `A..B`.
|
|
603
|
+
|
|
604
|
+
Read as `git diff A...B` (from the merge base), so a branch that is behind its base does not
|
|
605
|
+
count the base's newer files as its own. Raises ValueError when git cannot read the range."""
|
|
606
|
+
_check_range_ok(commit_range)
|
|
607
|
+
base, head = commit_range.split("..")
|
|
608
|
+
try:
|
|
609
|
+
done = subprocess.run(
|
|
610
|
+
["git", "diff", "--name-only", "--no-renames", "--relative", "-z",
|
|
611
|
+
f"{base}...{head}", "--"],
|
|
612
|
+
cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
613
|
+
timeout=30,
|
|
614
|
+
)
|
|
615
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
616
|
+
raise ValueError(f"cannot read commit range {commit_range}: {exc}") from exc
|
|
617
|
+
if done.returncode != 0:
|
|
618
|
+
why = next((ln.strip() for ln in done.stderr.splitlines() if ln.strip()), "git diff failed")
|
|
619
|
+
raise ValueError(f"cannot read commit range {commit_range}: {why}")
|
|
620
|
+
return {name for name in done.stdout.split("\0") if name}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _rev(root: Path, ref: str) -> str | None:
|
|
624
|
+
"""The commit `ref` names, or None when git cannot resolve it."""
|
|
625
|
+
try:
|
|
626
|
+
done = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
|
627
|
+
cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
628
|
+
except (OSError, subprocess.SubprocessError):
|
|
629
|
+
return None
|
|
630
|
+
return done.stdout.strip() or None if done.returncode == 0 else None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def dirty_paths(root: Path, paths: set[str]) -> list[str]:
|
|
634
|
+
"""The subset of `paths` with an uncommitted change (staged, unstaged or untracked), as
|
|
635
|
+
`git status --porcelain -- <paths>` reports them. Raises ValueError when git cannot say."""
|
|
636
|
+
names = sorted(paths)
|
|
637
|
+
dirty: set[str] = set()
|
|
638
|
+
for at in range(0, len(names), 200): # chunks keep the command line short on Windows
|
|
639
|
+
try:
|
|
640
|
+
done = subprocess.run(
|
|
641
|
+
["git", "--literal-pathspecs", "status", "--porcelain", "-z", "--no-renames",
|
|
642
|
+
"--untracked-files=all", "--", *names[at:at + 200]],
|
|
643
|
+
cwd=str(root), capture_output=True, text=True, encoding="utf-8",
|
|
644
|
+
errors="replace", timeout=60,
|
|
645
|
+
)
|
|
646
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
647
|
+
raise ValueError(f"cannot read git status: {exc}") from exc
|
|
648
|
+
if done.returncode != 0:
|
|
649
|
+
raise ValueError(f"cannot read git status: {done.stderr.strip()}")
|
|
650
|
+
dirty.update(entry[3:] for entry in done.stdout.split("\0") if len(entry) > 3)
|
|
651
|
+
return sorted(dirty)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _only_targets(root: Path, only: list[str]) -> tuple[set[Path], list[str]]:
|
|
655
|
+
"""The resolved `--only` plans, and a message for each name that is not a top-level plan."""
|
|
656
|
+
plans = (root / ".caddis" / "plans").resolve()
|
|
657
|
+
targets: set[Path] = set()
|
|
658
|
+
problems: list[str] = []
|
|
659
|
+
for name in only:
|
|
660
|
+
path = Path(name)
|
|
661
|
+
path = (path if path.is_absolute() else root / path).resolve()
|
|
662
|
+
if (path.parent != plans or path.suffix != ".md" or path.name.lower() == "readme.md"
|
|
663
|
+
or not path.is_file()):
|
|
664
|
+
problems.append(f"--only {name}: not a top-level plan file in .caddis/plans/")
|
|
665
|
+
else:
|
|
666
|
+
targets.add(path)
|
|
667
|
+
return targets, problems
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _limit_check_to(root: Path, report: Report, touched: set[str]) -> None:
|
|
671
|
+
"""Drop every --check finding whose file the commit range did not change."""
|
|
672
|
+
def keep(path: Path) -> bool:
|
|
673
|
+
try:
|
|
674
|
+
return path.relative_to(root).as_posix() in touched
|
|
675
|
+
except ValueError:
|
|
676
|
+
return False
|
|
677
|
+
|
|
678
|
+
report.check_flagged = [p for p in report.check_flagged if keep(p)]
|
|
679
|
+
report.violations = [(p, m) for p, m in report.violations if keep(p)]
|
|
680
|
+
report.tracker_done_open = [(p, s) for p, s in report.tracker_done_open if keep(p)]
|
|
681
|
+
report.open_rows_held = [(p, ids) for p, ids in report.open_rows_held if keep(p)]
|
|
682
|
+
|
|
683
|
+
|
|
471
684
|
def _is_legacy_exempt(repo_root: Path, path: Path) -> bool:
|
|
472
685
|
try:
|
|
473
686
|
rel = path.relative_to(repo_root / ".caddis").as_posix()
|
|
@@ -517,18 +730,47 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
|
|
|
517
730
|
|
|
518
731
|
|
|
519
732
|
def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
520
|
-
kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None
|
|
733
|
+
kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None,
|
|
734
|
+
only: list[str] | None = None) -> Report:
|
|
521
735
|
"""Plan every change first, then make all of them or none.
|
|
522
736
|
|
|
523
737
|
Nothing is written until the whole set is known and every destination is free. One existing
|
|
524
|
-
`done/<name>` (or an unreadable commit range) leaves every file exactly as it was.
|
|
738
|
+
`done/<name>` (or an unreadable commit range) leaves every file exactly as it was. `only`
|
|
739
|
+
names the plans this run may touch; nothing else is moved or stamped.
|
|
525
740
|
"""
|
|
526
741
|
report = Report()
|
|
742
|
+
if commit_range is not None:
|
|
743
|
+
try:
|
|
744
|
+
report.in_range = changed_paths(repo_root, commit_range)
|
|
745
|
+
except ValueError as exc:
|
|
746
|
+
return report.refuse(str(exc))
|
|
747
|
+
if check:
|
|
748
|
+
head = commit_range.split("..")[1]
|
|
749
|
+
if _rev(repo_root, head) != _rev(repo_root, "HEAD"):
|
|
750
|
+
return report.refuse(f"--check reads the working tree, so {commit_range} must "
|
|
751
|
+
"end at HEAD; check out that branch first")
|
|
752
|
+
try:
|
|
753
|
+
dirty = dirty_paths(repo_root, report.in_range) if report.in_range else []
|
|
754
|
+
except ValueError as exc:
|
|
755
|
+
return report.refuse(str(exc))
|
|
756
|
+
if dirty:
|
|
757
|
+
return report.refuse("uncommitted changes in files this range touched: "
|
|
758
|
+
f"{', '.join(dirty)}; commit them first")
|
|
759
|
+
only_set: set[Path] | None = None
|
|
760
|
+
if only is not None:
|
|
761
|
+
only_set, problems = _only_targets(repo_root, only)
|
|
762
|
+
if problems or not only_set:
|
|
763
|
+
report.errors.extend(problems or ["--only names no plan"])
|
|
764
|
+
report.refused = True
|
|
765
|
+
return report
|
|
766
|
+
kinds = ("plans",) # --only names plans: prompts and parking-lot items stay put
|
|
527
767
|
moves: list[Path] = []
|
|
528
768
|
rewrites: dict[Path, str] = {} # applied before the moves, so a failed move leaves a
|
|
529
769
|
# finished status at the top level, where the next run moves it by status alone
|
|
530
770
|
for kind in kinds:
|
|
531
771
|
items = scan(repo_root / ".caddis" / kind)
|
|
772
|
+
if only_set is not None:
|
|
773
|
+
items = [item for item in items if item.path.resolve() in only_set]
|
|
532
774
|
for item in items:
|
|
533
775
|
if kind == PARKING_LOT_DIR:
|
|
534
776
|
_check_parking_lot_item(item, report)
|
|
@@ -539,6 +781,20 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
539
781
|
continue
|
|
540
782
|
cls = classify_status(item.status)
|
|
541
783
|
if cls == "terminal":
|
|
784
|
+
if (kind == "plans"
|
|
785
|
+
and (item.status or "").strip().lower() in {"done", "shipped", "implemented"}):
|
|
786
|
+
open_ids = [
|
|
787
|
+
pid for pid, row_line in open_tracker_rows(item.path)
|
|
788
|
+
if not _row_excused(repo_root, row_line)
|
|
789
|
+
]
|
|
790
|
+
if open_ids:
|
|
791
|
+
report.open_rows_held.append((item.path, open_ids))
|
|
792
|
+
if check:
|
|
793
|
+
report._flag(
|
|
794
|
+
item.path,
|
|
795
|
+
f"held (open Tracker rows): rows {', '.join(open_ids)}",
|
|
796
|
+
)
|
|
797
|
+
continue
|
|
542
798
|
moves.append(item.path)
|
|
543
799
|
continue
|
|
544
800
|
if cls == "none":
|
|
@@ -551,6 +807,10 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
551
807
|
report.stale_suspects.append(item.path)
|
|
552
808
|
continue
|
|
553
809
|
# active status
|
|
810
|
+
if item.phases_all_done and kind == "plans" and check:
|
|
811
|
+
report.tracker_done_open.append((item.path, str(item.status).strip()))
|
|
812
|
+
if item.path not in report.check_flagged:
|
|
813
|
+
report.check_flagged.append(item.path)
|
|
554
814
|
if item.phases_all_done and kind == "plans":
|
|
555
815
|
text = _read_exact(item.path)
|
|
556
816
|
new = None if text is None else _set_header(text, {"status": "done"})
|
|
@@ -563,6 +823,20 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
563
823
|
report.stale_suspects.append(item.path)
|
|
564
824
|
if commit_range is not None and PARKING_LOT_DIR in kinds:
|
|
565
825
|
_plan_parked(repo_root, commit_range, report, moves, rewrites)
|
|
826
|
+
if check and report.in_range is not None:
|
|
827
|
+
_limit_check_to(repo_root, report, report.in_range)
|
|
828
|
+
if only_set is not None:
|
|
829
|
+
planned = {src.resolve() for src in moves}
|
|
830
|
+
# A plan held for open Tracker rows is already reported (exit 2); it is not a failed request.
|
|
831
|
+
held = {p.resolve() for p, _ in report.open_rows_held}
|
|
832
|
+
for item in items: # kinds is ("plans",) here, so `items` is the named plans
|
|
833
|
+
if item.path.resolve() not in planned and item.path.resolve() not in held:
|
|
834
|
+
why = ("no frontmatter status" if classify_status(item.status) == "none" else
|
|
835
|
+
f"status {item.status} and the Tracker is not all done")
|
|
836
|
+
report.not_moved.append((item.path, why))
|
|
837
|
+
seen = {item.path.resolve() for item in items}
|
|
838
|
+
for path in sorted(only_set - seen):
|
|
839
|
+
report.not_moved.append((path, "not a plan this run reads"))
|
|
566
840
|
|
|
567
841
|
report.collisions.extend(src for src in moves if _collides(src))
|
|
568
842
|
if not apply:
|
|
@@ -573,6 +847,28 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
573
847
|
# Past this point only an I/O failure can stop the run. Every step is safe to repeat: a
|
|
574
848
|
# written status or stamp that did not move yet is moved by status on the next run, and a
|
|
575
849
|
# note already present is not appended again.
|
|
850
|
+
# Never move, stage or rewrite a file with uncommitted changes to tracked content: in a shared checkout
|
|
851
|
+
# that is another session's edit, and a `git mv` stages it for whoever commits next (2026-10-15,
|
|
852
|
+
# serve-sight). A status that tidy itself wrote is such a change too, so that plan moves on a later run,
|
|
853
|
+
# after the status write is committed.
|
|
854
|
+
if _is_git_repo(repo_root):
|
|
855
|
+
dirty = _dirty_paths(repo_root)
|
|
856
|
+
if dirty is None:
|
|
857
|
+
report.dirty_check_failed = "git status could not be read"
|
|
858
|
+
return report
|
|
859
|
+
|
|
860
|
+
def _is_dirty(path: Path) -> bool:
|
|
861
|
+
try:
|
|
862
|
+
rel_path = path.relative_to(repo_root).as_posix()
|
|
863
|
+
except ValueError:
|
|
864
|
+
return True # cannot place it in the repo: fail closed, leave it alone
|
|
865
|
+
return rel_path in dirty
|
|
866
|
+
|
|
867
|
+
for path in sorted({*moves, *rewrites}, key=str):
|
|
868
|
+
if _is_dirty(path) and path not in report.dirty_skipped:
|
|
869
|
+
report.dirty_skipped.append(path)
|
|
870
|
+
moves = [src for src in moves if src not in report.dirty_skipped]
|
|
871
|
+
rewrites = {path: text for path, text in rewrites.items() if path not in report.dirty_skipped}
|
|
576
872
|
try:
|
|
577
873
|
for path, text in rewrites.items():
|
|
578
874
|
atomic_write(path, text)
|
|
@@ -834,20 +1130,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
834
1130
|
ap.add_argument("--repo-root", default=".", help="repo root containing .caddis/ (default: cwd)")
|
|
835
1131
|
ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
|
|
836
1132
|
ap.add_argument("--check", action="store_true",
|
|
837
|
-
help="conformance check - exit 1 if any top-level artifact lacks a known
|
|
1133
|
+
help="conformance check - exit 1 if any top-level artifact lacks a known "
|
|
1134
|
+
"status, or a plan's Tracker is all done while its status is active")
|
|
838
1135
|
ap.add_argument("--commit-range", default=None,
|
|
839
1136
|
help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
|
|
840
|
-
"commits only, e.g. origin/main..HEAD (default: read no trailers)"
|
|
1137
|
+
"commits only, e.g. origin/main..HEAD (default: read no trailers); "
|
|
1138
|
+
"with --check, check only the files this range changed")
|
|
1139
|
+
ap.add_argument("--only", nargs="+", default=None, metavar="PLAN",
|
|
1140
|
+
help="touch only these top-level plans (.caddis/plans/<name>.md); nothing "
|
|
1141
|
+
"else is moved or stamped")
|
|
841
1142
|
ap.add_argument("--prune", action="store_true",
|
|
842
1143
|
help="list old local state under .caddis/ (with --apply: delete it)")
|
|
843
1144
|
args = ap.parse_args(argv)
|
|
1145
|
+
# A status or path the console cannot encode prints as `?`, never a crash (a cp1252 pipe).
|
|
1146
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1147
|
+
try:
|
|
1148
|
+
stream.reconfigure(errors="replace")
|
|
1149
|
+
except (AttributeError, ValueError, OSError):
|
|
1150
|
+
pass
|
|
844
1151
|
|
|
845
1152
|
repo_root = Path(args.repo_root).resolve()
|
|
846
1153
|
if args.prune:
|
|
847
|
-
if args.check or args.commit_range:
|
|
848
|
-
ap.error("--prune runs alone: not with --check
|
|
1154
|
+
if args.check or args.commit_range or args.only:
|
|
1155
|
+
ap.error("--prune runs alone: not with --check, --commit-range or --only")
|
|
849
1156
|
return _print_prune(repo_root, prune(repo_root, apply=args.apply), args.apply)
|
|
850
|
-
|
|
1157
|
+
if args.only and args.check:
|
|
1158
|
+
ap.error("--only is for --apply (or its dry run), not --check")
|
|
1159
|
+
report = tidy(repo_root, apply=args.apply, check=args.check, commit_range=args.commit_range,
|
|
1160
|
+
only=args.only)
|
|
1161
|
+
if report.refused:
|
|
1162
|
+
# Nothing was read, so nothing else is true enough to print: no unfiltered list.
|
|
1163
|
+
for msg in report.errors:
|
|
1164
|
+
print(f"ERROR: {msg}")
|
|
1165
|
+
return 1
|
|
851
1166
|
|
|
852
1167
|
def rel(p: Path) -> str:
|
|
853
1168
|
return p.relative_to(repo_root).as_posix()
|
|
@@ -867,10 +1182,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
867
1182
|
label = "moved" if args.apply else "would move"
|
|
868
1183
|
for p in moved_or_would:
|
|
869
1184
|
print(f" {label}: {rel(p)}")
|
|
1185
|
+
for p, rows in report.open_rows_held:
|
|
1186
|
+
print(f" held (open Tracker rows): {rel(p)}: rows {', '.join(rows)}")
|
|
1187
|
+
for p in report.dirty_skipped:
|
|
1188
|
+
print(f" skipped (uncommitted changes): {rel(p)}")
|
|
1189
|
+
if report.dirty_check_failed:
|
|
1190
|
+
print(f" skipped all moves: {report.dirty_check_failed}")
|
|
870
1191
|
for name, why in report.parked_refused:
|
|
871
1192
|
print(f" REFUSED trailer `{name}`: {why}")
|
|
872
1193
|
for p in report.collisions:
|
|
873
1194
|
print(f" COLLISION (done/ already has this name): {rel(p)}")
|
|
1195
|
+
for p, why in report.not_moved:
|
|
1196
|
+
print(f" NOT MOVED (--only): {rel(p)}: {why}")
|
|
874
1197
|
if report.blocked:
|
|
875
1198
|
print(" BLOCKED: " + ("nothing was changed." if args.apply else
|
|
876
1199
|
"--apply would change nothing.")
|
|
@@ -878,8 +1201,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
878
1201
|
for p in report.stale_suspects:
|
|
879
1202
|
print(f" stale-suspect (all phases done, status still active): {rel(p)}")
|
|
880
1203
|
if args.check:
|
|
881
|
-
|
|
882
|
-
|
|
1204
|
+
# By the list itself: after a --commit-range filter, "not flagged" no longer means legacy.
|
|
1205
|
+
exempt = [p for p in report.frontmatter_less if _is_legacy_exempt(repo_root, p)]
|
|
1206
|
+
detailed = {p for p, _ in report.violations} | {p for p, _ in report.tracker_done_open}
|
|
1207
|
+
if report.in_range is not None:
|
|
1208
|
+
# Out of range is not legacy: list only the grandfathered files this range touched.
|
|
1209
|
+
exempt = [p for p in exempt if rel(p) in report.in_range]
|
|
1210
|
+
for p, status in report.tracker_done_open:
|
|
1211
|
+
if _collides(p):
|
|
1212
|
+
print(f" CHECK VIOLATION: plan {rel(p)}: tracker done, but done/{p.name} "
|
|
1213
|
+
"already exists (rename one of them)")
|
|
1214
|
+
else:
|
|
1215
|
+
print(f" CHECK VIOLATION: plan {rel(p)}: tracker done, status {status}"
|
|
1216
|
+
f" (run caddis_tidy.py --apply --only {rel(p)} and commit the move)")
|
|
883
1217
|
for p, msg in report.violations:
|
|
884
1218
|
print(f" CHECK VIOLATION: {rel(p)}: {msg}")
|
|
885
1219
|
for p in report.check_flagged:
|
|
@@ -889,10 +1223,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
889
1223
|
print(f" (legacy, grandfathered): {rel(p)}")
|
|
890
1224
|
|
|
891
1225
|
# A collision is reported, not fatal: /handoff and /implement surface it and carry on.
|
|
892
|
-
|
|
1226
|
+
# A named plan that did not move is a failed request, and with --only a collision is one too.
|
|
1227
|
+
if report.errors or report.not_moved or (args.only and report.collisions):
|
|
893
1228
|
return 1
|
|
894
1229
|
if args.check:
|
|
895
1230
|
return 1 if report.check_violations() else 0
|
|
1231
|
+
if report.open_rows_held:
|
|
1232
|
+
return 2
|
|
896
1233
|
return 0
|
|
897
1234
|
|
|
898
1235
|
|
|
@@ -308,12 +308,67 @@ def _plan_rows(plan: Path | None) -> list[dict]:
|
|
|
308
308
|
return rows
|
|
309
309
|
|
|
310
310
|
|
|
311
|
+
def _split_cells(line: str) -> list[str]:
|
|
312
|
+
r"""Stripped cells of one table line, split on unescaped pipes only (`a \| b` is one cell)."""
|
|
313
|
+
parts = re.split(r"(?<!\\)\|", line.strip())
|
|
314
|
+
if parts and parts[0] == "":
|
|
315
|
+
parts.pop(0)
|
|
316
|
+
if parts and parts[-1] == "":
|
|
317
|
+
parts.pop(-1)
|
|
318
|
+
return [part.strip() for part in parts]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _open_work_rows(root: Path) -> list[dict]:
|
|
322
|
+
path = _artifact(root) / "open-work.md"
|
|
323
|
+
if path.is_symlink() or not path.is_file():
|
|
324
|
+
return []
|
|
325
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
326
|
+
header, body = split_document(text)
|
|
327
|
+
if header.get("type") != "open-work":
|
|
328
|
+
return []
|
|
329
|
+
rows: list[dict] = []
|
|
330
|
+
columns: list[str] | None = None
|
|
331
|
+
id_idx = status_idx = title_idx = 0
|
|
332
|
+
for raw_line in body.splitlines():
|
|
333
|
+
line = raw_line.strip()
|
|
334
|
+
if not line.startswith("|"):
|
|
335
|
+
columns = None
|
|
336
|
+
continue
|
|
337
|
+
cells = _split_cells(line) # unescaped pipes only, as caddis_rows.split_cells
|
|
338
|
+
if not cells or all(c and set(c) <= set("-: ") and "-" in c for c in cells):
|
|
339
|
+
continue
|
|
340
|
+
if columns is None:
|
|
341
|
+
lowered = [c.lower() for c in cells]
|
|
342
|
+
if "id" in lowered and "status" in lowered:
|
|
343
|
+
columns = lowered
|
|
344
|
+
id_idx = columns.index("id")
|
|
345
|
+
status_idx = columns.index("status")
|
|
346
|
+
title_idx = next(
|
|
347
|
+
(i for i, col in enumerate(columns) if col in {"task", "title", "item"}),
|
|
348
|
+
id_idx + 1,
|
|
349
|
+
)
|
|
350
|
+
continue
|
|
351
|
+
if len(cells) != len(columns):
|
|
352
|
+
continue
|
|
353
|
+
status = cells[status_idx].strip().lower()
|
|
354
|
+
first = status.split(" ", 1)[0] if status else ""
|
|
355
|
+
if _is_done(status) or first in {"not-needed", "✓", "✔"}:
|
|
356
|
+
continue
|
|
357
|
+
row_id = cells[id_idx].replace("**", "").replace("__", "").replace("`", "").strip()
|
|
358
|
+
if not row_id:
|
|
359
|
+
continue
|
|
360
|
+
title = cells[title_idx] if title_idx < len(cells) else ""
|
|
361
|
+
rows.append({"id": row_id, "kind": "open-work", "title": title})
|
|
362
|
+
return rows
|
|
363
|
+
|
|
364
|
+
|
|
311
365
|
def open_work(root: Path, workstream: str | None = None) -> list[dict]:
|
|
312
366
|
rows = _plan_rows(_active_plan(root, workstream))
|
|
313
367
|
for name in (None, workstream) if workstream and workstream != "inbox" else (None,):
|
|
314
368
|
rows.extend({"id": item["id"], "kind": "todo", "title": item["title"],
|
|
315
369
|
**{key: item[key] for key in ("resolves", "partial") if key in item}}
|
|
316
370
|
for item in list_items(root, name))
|
|
371
|
+
rows.extend(_open_work_rows(root))
|
|
317
372
|
return rows
|
|
318
373
|
|
|
319
374
|
|