@caddis/cli 0.4.34 → 0.4.36

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.
Files changed (170) hide show
  1. package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
  6. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
  8. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
  11. package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
  13. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
  14. package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
  15. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
  16. package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
  17. package/bundles/antigravity-plugin/session_end_agy.py +16 -1
  18. package/bundles/antigravity-plugin/skills/_registry.md +35 -32
  19. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
  21. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
  22. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
  24. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
  26. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
  28. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
  31. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
  33. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
  34. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
  36. package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
  37. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
  40. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
  41. package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
  42. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
  43. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
  44. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
  46. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
  50. package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
  51. package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
  52. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
  53. package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
  54. package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
  55. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
  56. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
  57. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
  58. package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
  59. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
  60. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
  61. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
  62. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
  64. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
  65. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
  66. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
  67. package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
  68. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  69. package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
  70. package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
  71. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
  72. package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
  73. package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
  74. package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
  76. package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
  77. package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
  78. package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
  79. package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
  80. package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
  81. package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
  82. package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
  83. package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
  84. package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
  85. package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
  86. package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
  87. package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
  88. package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
  89. package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
  90. package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
  91. package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
  92. package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
  93. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
  94. package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
  95. package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
  96. package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
  97. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
  98. package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
  99. package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
  100. package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
  101. package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
  102. package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
  103. package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
  104. package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
  105. package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
  106. package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
  107. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  108. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
  109. package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
  110. package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
  111. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
  112. package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
  113. package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
  114. package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
  115. package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
  116. package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
  117. package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
  118. package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
  119. package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
  120. package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
  121. package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
  122. package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
  123. package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
  124. package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
  125. package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
  126. package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
  127. package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
  128. package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
  129. package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
  130. package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
  131. package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
  132. package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
  133. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  134. package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
  135. package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
  136. package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
  137. package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
  138. package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
  139. package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
  140. package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
  141. package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
  142. package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
  143. package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
  144. package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
  145. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
  146. package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
  147. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
  148. package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
  149. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
  150. package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
  151. package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
  152. package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
  153. package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
  154. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
  155. package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
  156. package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
  157. package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
  158. package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
  159. package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
  160. package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
  161. package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
  162. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
  163. package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
  164. package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
  165. package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
  166. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
  167. package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
  168. package/bundles/codex/AGENTS.md +5 -1
  169. package/bundles/manifest.json +3 -3
  170. package/package.json +1 -1
@@ -0,0 +1,462 @@
1
+ """Locked, local to-do lists and a read-only view of active plan work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ try:
14
+ import caddis_exit
15
+ from caddis_file_lock import atomic_write, locked_path
16
+ from caddis_frontmatter import parse, split_document
17
+ from caddis_workstreams import _artifact, _name
18
+ except ModuleNotFoundError as exc:
19
+ if exc.name not in {"caddis_exit", "caddis_file_lock", "caddis_frontmatter", "caddis_workstreams"}:
20
+ raise
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
23
+ import caddis_exit
24
+ from caddis_file_lock import atomic_write, locked_path
25
+ from caddis_frontmatter import parse, split_document
26
+ from caddis_workstreams import _artifact, _name
27
+
28
+
29
+ _ITEM = re.compile(r'^- \[([ x])\] (.+?) <!-- caddis: (\{.*\}) -->$')
30
+ _PHASE = re.compile(r'^\|\s*(\d+)\s*\|', re.M)
31
+ _WORD = re.compile(r'[a-z0-9]{3,}')
32
+ _PLAN_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,150}")
33
+ # Trailing status markers on phase headings: arrows/clocks (U+2300-U+27BF), emoji, VS16.
34
+ _STATUS_MARKER = re.compile("[\\s\u2300-\u27bf\U0001f300-\U0001faff\ufe0f]+$")
35
+
36
+
37
+ def _path(root: Path, workstream: str | None) -> tuple[Path, Path]:
38
+ artifact = _artifact(root)
39
+ name = _name(workstream) if workstream is not None else "inbox"
40
+ folder = artifact / "todo-list"
41
+ if folder.is_symlink() or not folder.resolve().is_relative_to(artifact.resolve()):
42
+ raise ValueError("todo-list path escapes artifact root")
43
+ path = folder / f"{name}.md"
44
+ if path.is_symlink(): # reads are unlocked, so refuse here as well as in locked_path
45
+ raise ValueError("to-do file may not be a symlink")
46
+ return artifact, path
47
+
48
+
49
+ def _read(path: Path, workstream: str | None) -> list[dict]:
50
+ if not path.exists():
51
+ return []
52
+ text = path.read_text(encoding="utf-8")
53
+ header, body = split_document(text)
54
+ name = workstream or "inbox"
55
+ if header.get("type") != "todo" or header.get("workstream") != name or header.get("status") not in {"current", "done"}:
56
+ raise ValueError(f"invalid to-do header: {path}")
57
+ items = []
58
+ for line in body.splitlines():
59
+ if not line.strip() or line == "# To-dos":
60
+ continue
61
+ match = _ITEM.fullmatch(line)
62
+ if not match:
63
+ raise ValueError(f"invalid to-do row: {path}")
64
+ data = json.loads(match.group(3))
65
+ if not isinstance(data, dict) or not isinstance(data.get("id"), str):
66
+ raise ValueError(f"invalid to-do item: {path}")
67
+ uuid.UUID(data["id"])
68
+ if "resolves" in data:
69
+ _resolves_path(path.parents[2], data["resolves"])
70
+ items.append({"id": data["id"], "title": match.group(2), "done": match.group(1) == "x",
71
+ **{key: data[key] for key in ("resolves", "partial") if key in data}})
72
+ if len({item["id"] for item in items}) != len(items):
73
+ raise ValueError(f"duplicate to-do id: {path}")
74
+ expected = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
75
+ if header["status"] != expected:
76
+ raise ValueError(f"to-do status mismatch: {path}")
77
+ return items
78
+
79
+
80
+ def _write(path: Path, root: Path, workstream: str | None, items: list[dict]) -> None:
81
+ status = "done" if workstream not in (None, "inbox") and items and all(item["done"] for item in items) else "current"
82
+ lines = ["---", "type: todo", f"status: {status}", f"workstream: {workstream or 'inbox'}",
83
+ "---", "", "# To-dos", ""]
84
+ for item in items:
85
+ metadata = {"id": item["id"]}
86
+ for key in ("resolves", "partial"):
87
+ if key in item:
88
+ metadata[key] = item[key]
89
+ box = "x" if item["done"] else " "
90
+ lines.append(f'- [{box}] {item["title"]} <!-- caddis: {json.dumps(metadata, sort_keys=True)} -->')
91
+ atomic_write(path, "\n".join(lines) + "\n", root=root)
92
+
93
+
94
+ def _title(value: str) -> str:
95
+ title = " ".join(value.split())
96
+ if not title or len(title) > 300 or "<!--" in title or "-->" in title:
97
+ raise ValueError("to-do text must be one plain line (1-300 characters)")
98
+ return title
99
+
100
+
101
+ def _resolves_path(root: Path, value: str) -> Path:
102
+ if not isinstance(value, str):
103
+ raise ValueError("resolves must name a parking-lot file")
104
+ name = value.removeprefix("parking-lot/")
105
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*\.md", name) or ".." in name:
106
+ raise ValueError("resolves must name a flat parking-lot .md file")
107
+ parking = _artifact(root) / "parking-lot"
108
+ target = parking / name
109
+ if parking.is_symlink() or target.is_symlink() or not target.is_file() or not target.resolve().is_relative_to(parking.resolve()):
110
+ raise ValueError("unknown parking-lot file")
111
+ if parse(target.read_text(encoding="utf-8")).get("type") != "parking-lot":
112
+ raise ValueError("target is not a parking-lot item")
113
+ return target
114
+
115
+
116
+ def _suggest(root: Path, title: str) -> list[str]:
117
+ parking = _artifact(root) / "parking-lot"
118
+ if not parking.is_dir() or parking.is_symlink():
119
+ return []
120
+ words = set(_WORD.findall(title.lower()))
121
+ found = []
122
+ for path in sorted(parking.glob("*.md")):
123
+ if path.is_symlink() or path.name == "README.md":
124
+ continue
125
+ if words & set(_WORD.findall(path.stem.lower())) or path.name.lower() in title.lower():
126
+ found.append(f"parking-lot/{path.name}")
127
+ return found
128
+
129
+
130
+ def add(root: Path, title: str, workstream: str | None = None, *, resolves: str | None = None,
131
+ partial: bool = False) -> dict:
132
+ title = _title(title)
133
+ artifact, path = _path(root, workstream)
134
+ if partial and not resolves:
135
+ raise ValueError("--partial requires --resolves")
136
+ linked = f"parking-lot/{_resolves_path(root, resolves).name}" if resolves else None
137
+ with locked_path(path, root=artifact):
138
+ items = _read(path, workstream)
139
+ item = {"id": str(uuid.uuid4()), "title": title, "done": False}
140
+ if linked:
141
+ item["resolves"] = linked
142
+ if partial:
143
+ item["partial"] = True
144
+ items.append(item)
145
+ _write(path, artifact, workstream, items)
146
+ return item
147
+
148
+
149
+ def done(root: Path, item_id: str, workstream: str) -> dict:
150
+ artifact, path = _path(root, workstream)
151
+ with locked_path(path, root=artifact):
152
+ items = _read(path, workstream)
153
+ matches = [item for item in items if item["id"] == item_id]
154
+ if not matches:
155
+ raise ValueError("unknown to-do id")
156
+ item = matches[0]
157
+ if not item["done"]:
158
+ item["done"] = True
159
+ _write(path, artifact, workstream, items)
160
+ return item
161
+
162
+
163
+ def list_items(root: Path, workstream: str | None = None) -> list[dict]:
164
+ _, path = _path(root, workstream)
165
+ return [item for item in _read(path, workstream) if not item["done"]]
166
+
167
+
168
+ def _is_done(status: str) -> bool:
169
+ """A Tracker status cell that means done: "done", "done (note)" or a leading check mark.
170
+ A cell that says work is still pending is not done (owner decision 2026-09-27)."""
171
+ lowered = status.strip().lower()
172
+ first = lowered.split(" ", 1)[0] if lowered else ""
173
+ if "pending" in lowered:
174
+ return False
175
+ return first in {"done", "✅"} or first.startswith("✅")
176
+
177
+
178
+ def _active_plan(root: Path, workstream: str | None) -> Path | None:
179
+ artifact = _artifact(root)
180
+ plans_dir = artifact / "plans"
181
+ if plans_dir.is_symlink() or not plans_dir.is_dir():
182
+ return None
183
+ if workstream:
184
+ relay = artifact / "relay" / f"{_name(workstream)}.md"
185
+ if relay.is_file() and not relay.is_symlink():
186
+ named = parse(relay.read_text(encoding="utf-8")).get("plan")
187
+ if isinstance(named, str) and named:
188
+ # Only a bare file name inside plans/: a relay cannot point the view elsewhere.
189
+ stem = Path(named.replace("\\", "/")).name.removesuffix(".md")
190
+ if _PLAN_NAME.fullmatch(stem):
191
+ candidate = plans_dir / f"{stem}.md"
192
+ if candidate.is_file() and not candidate.is_symlink():
193
+ return candidate
194
+ plans = _current_plans(plans_dir)
195
+ if workstream:
196
+ matches = [p for p in plans if parse(p.read_text(encoding="utf-8")).get("feature") == workstream]
197
+ if len(matches) == 1:
198
+ return matches[0]
199
+ if len(matches) > 1: # ambiguous by name: never guess further
200
+ return None
201
+ elif len(plans) == 1:
202
+ return plans[0]
203
+ # Several current plans and no name match: the one this branch has changed is the active
204
+ # one. Without this, the first session on a branch (no relay yet) could never seed. Only
205
+ # for no name or a branch-derived name: a named plan workstream must not borrow another plan.
206
+ if workstream and not workstream.startswith("branch."):
207
+ return None
208
+ touched = _plans_changed_on_branch(root, plans)
209
+ return touched[0] if len(touched) == 1 else None
210
+
211
+
212
+ def _current_plans(plans_dir: Path) -> list[Path]:
213
+ return sorted(p for p in plans_dir.glob("*.md") if not p.is_symlink()
214
+ and parse(p.read_text(encoding="utf-8")).get("status") == "current")
215
+
216
+
217
+ def active_plan(root: Path) -> tuple[str | None, list[str]]:
218
+ """The active plan as a repo-relative POSIX path, else None and the candidates."""
219
+ base = Path(root).resolve()
220
+
221
+ def relative(path: Path) -> str:
222
+ return path.resolve().relative_to(base).as_posix()
223
+
224
+ try:
225
+ plan = _active_plan(root, None)
226
+ if plan is not None:
227
+ return relative(plan), []
228
+ plans_dir = _artifact(root) / "plans"
229
+ if plans_dir.is_symlink() or not plans_dir.is_dir():
230
+ return None, []
231
+ return None, [relative(p) for p in _current_plans(plans_dir)]
232
+ except ValueError: # a plan that resolves outside the repo (e.g. a symlinked .caddis)
233
+ return None, []
234
+
235
+
236
+ def _plans_changed_on_branch(root: Path, plans: list[Path]) -> list[Path]:
237
+ """Current plans with commits between the default branch and HEAD; [] when unknown."""
238
+ def git(*args: str) -> str | None:
239
+ try:
240
+ done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
241
+ encoding="utf-8", errors="replace", timeout=5, check=False)
242
+ except (OSError, subprocess.SubprocessError):
243
+ return None
244
+ return done.stdout.strip() if done.returncode == 0 else None
245
+
246
+ # The remote's own default branch first; the fixed names only when origin/HEAD is unset.
247
+ remote_default = git("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
248
+ # Local names before remote-tracking ones: an unfetched origin/main is older than main,
249
+ # and base..HEAD would then count plans that main already has.
250
+ local_default = remote_default.split("/", 1)[1] if remote_default and "/" in remote_default else ""
251
+ guesses = tuple(name for name in (local_default, remote_default) if name) + (
252
+ "main", "master", "trunk", "develop", "origin/main", "origin/master", "origin/trunk", "origin/develop")
253
+ base = next((b for b in guesses if git("merge-base", "HEAD", b)), None)
254
+ if base is None:
255
+ return []
256
+ changed = git("log", "--name-only", "--format=", f"{base}..HEAD", "--", ".caddis/plans")
257
+ if not changed:
258
+ return []
259
+ names = {Path(line).name for line in changed.splitlines() if line.strip()}
260
+ return [p for p in plans if p.name in names]
261
+
262
+
263
+ def tracker_table(text: str) -> tuple[list[str], list[str]] | None:
264
+ """The plan's `## Tracker` table: its lower-cased header columns and its other lines.
265
+
266
+ None when there is no Tracker heading line, no `| Phase |` header or no Status column. The
267
+ lines are every line of the section except the header; callers pick the rows they trust.
268
+ One reader, so the open-work view and caddis_tidy never disagree about the same table.
269
+ """
270
+ # A real heading line only: plan prose may mention "## Tracker" inline before the section.
271
+ heading = re.search(r"^## Tracker\b.*$", text, re.M)
272
+ if heading is None:
273
+ return None
274
+ section = text[heading.end():].split("\n## ", 1)[0]
275
+ table = section.splitlines()
276
+ at = next((i for i, line in enumerate(table) if re.match(r"\|\s*Phase\s*\|", line)), None)
277
+ if at is None:
278
+ return None
279
+ columns = [cell.strip().lower() for cell in table[at].strip("|").split("|")]
280
+ if "status" not in columns:
281
+ return None
282
+ return columns, table[:at] + table[at + 1:]
283
+
284
+
285
+ def _plan_rows(plan: Path | None) -> list[dict]:
286
+ if plan is None:
287
+ return []
288
+ text = plan.read_text(encoding="utf-8")
289
+ table = tracker_table(text)
290
+ if table is None:
291
+ return []
292
+ columns, lines = table
293
+ rows = []
294
+ for line in lines:
295
+ match = _PHASE.match(line)
296
+ if not match:
297
+ continue
298
+ cells = [cell.strip() for cell in line.strip("|").split("|")]
299
+ if len(cells) != len(columns) or _is_done(cells[columns.index("status")]):
300
+ continue
301
+ number = match.group(1)
302
+ heading = re.search(rf"^### Phase {number}\s+[-—]\s+(.+)$", text, re.M)
303
+ # Drop the heading's trailing status marker (🔲, ⏳, ✅ ...): status lives in the Tracker.
304
+ title = _STATUS_MARKER.sub("", heading.group(1)) if heading else f"Phase {number}"
305
+ rows.append({"id": f"phase:{number}", "kind": "phase", "title": title})
306
+ return rows
307
+
308
+
309
+ def open_work(root: Path, workstream: str | None = None) -> list[dict]:
310
+ rows = _plan_rows(_active_plan(root, workstream))
311
+ for name in (None, workstream) if workstream and workstream != "inbox" else (None,):
312
+ rows.extend({"id": item["id"], "kind": "todo", "title": item["title"],
313
+ **{key: item[key] for key in ("resolves", "partial") if key in item}}
314
+ for item in list_items(root, name))
315
+ return rows
316
+
317
+
318
+ def _extract_completed_ids(text: str) -> set[str]:
319
+ """Open-work ids of the done rows in a session-state file's "## Tasks" table.
320
+
321
+ Only the marker session_state.py writes after a task's subject counts, as in
322
+ ``| 3 | done | Fix x <!-- caddis: {"id": "..."} --> |``. A UUID that merely appears in a
323
+ task's text is never trusted: "depends on <uuid>" must not close that other item.
324
+ """
325
+ ids: set[str] = set()
326
+ in_tasks = False
327
+ for line in text.splitlines():
328
+ row = line.strip()
329
+ if row.startswith("## "):
330
+ in_tasks = row == "## Tasks"
331
+ continue
332
+ if not in_tasks or not row.startswith("|") or row.startswith("|---"):
333
+ continue
334
+ cells = [cell.strip() for cell in row.strip("|").split("|")]
335
+ # Status is the second cell; a "|" inside the subject only shifts later cells.
336
+ if len(cells) < 3 or cells[1].lower() not in {"done", "completed"}:
337
+ continue
338
+ marker = re.search(r"<!--\s*caddis:\s*(\{.*?\})\s*-->\s*\|?$", row)
339
+ if not marker:
340
+ continue
341
+ try:
342
+ value = json.loads(marker.group(1)).get("id")
343
+ except (ValueError, AttributeError):
344
+ continue
345
+ if isinstance(value, str) and value:
346
+ ids.add(value.lower())
347
+ return ids
348
+
349
+
350
+ def reconcile(root: Path, session_state: Path) -> list[str]:
351
+ """Reconcile completed task rows from session-state into todo-lists.
352
+
353
+ Marks ad-hoc todo items done by metadata ID.
354
+ Never edits the Tracker; phase rows are ignored.
355
+ """
356
+ if not session_state.is_file():
357
+ return []
358
+
359
+ text = session_state.read_text(encoding="utf-8", errors="replace")
360
+ completed_ids = _extract_completed_ids(text)
361
+ if not completed_ids:
362
+ return []
363
+
364
+ artifact = _artifact(root)
365
+ todo_dir = artifact / "todo-list"
366
+ if not todo_dir.is_dir():
367
+ return []
368
+
369
+ reconciled: list[str] = []
370
+ # Ignore phase rows — Tracker is never edited!
371
+ adhoc_ids = {cid for cid in completed_ids if not cid.startswith("phase:")}
372
+ if not adhoc_ids:
373
+ return []
374
+
375
+ wanted = {a.lower() for a in adhoc_ids}
376
+ for todo_file in todo_dir.glob("*.md"):
377
+ if todo_file.is_symlink() or not todo_file.is_file():
378
+ continue
379
+ workstream_name = todo_file.stem
380
+ try:
381
+ items = _read(todo_file, workstream_name)
382
+ except (ValueError, OSError):
383
+ continue # a hand-edited file caddis cannot read; the others still reconcile
384
+ for item in items:
385
+ item_id = item.get("id")
386
+ if item_id and item_id.lower() in wanted and not item.get("done"):
387
+ done(root, item_id, workstream_name)
388
+ reconciled.append(item_id)
389
+
390
+ return reconciled
391
+
392
+
393
+ def main(argv: list[str] | None = None) -> int:
394
+ # Titles carry non-ASCII text; a Windows console defaults to a legacy code page.
395
+ for stream in (sys.stdout, sys.stderr):
396
+ if hasattr(stream, "reconfigure"):
397
+ stream.reconfigure(encoding="utf-8", errors="replace")
398
+ parser = argparse.ArgumentParser(description=__doc__)
399
+ parser.add_argument("--root", type=Path, default=Path.cwd(), help=argparse.SUPPRESS)
400
+ commands = parser.add_subparsers(dest="command", required=True)
401
+ listing = commands.add_parser("list")
402
+ listing.add_argument("workstream", nargs="?")
403
+ adding = commands.add_parser("add")
404
+ adding.add_argument("text")
405
+ adding.add_argument("--workstream")
406
+ adding.add_argument("--resolves")
407
+ adding.add_argument("--partial", action="store_true")
408
+ finishing = commands.add_parser("done")
409
+ finishing.add_argument("id")
410
+ finishing.add_argument("--workstream", required=True)
411
+ view = commands.add_parser("open-work")
412
+ view.add_argument("--workstream")
413
+ view.add_argument("--json", action="store_true")
414
+ reconcile_p = commands.add_parser("reconcile")
415
+ reconcile_p.add_argument("--session-state", type=Path, required=True)
416
+ commands.add_parser("active-plan")
417
+ args = parser.parse_args(argv)
418
+ try:
419
+ if args.command == "add":
420
+ item = add(args.root, args.text, args.workstream, resolves=args.resolves, partial=args.partial)
421
+ print(item["id"])
422
+ if not args.resolves:
423
+ for suggestion in _suggest(args.root, item["title"]):
424
+ print(f"Suggested parking item: {suggestion}")
425
+ elif args.command == "done":
426
+ item = done(args.root, args.id, args.workstream)
427
+ if "resolves" in item:
428
+ prefix = "Partly-Resolves-Parked" if item.get("partial") else "Resolves-Parked"
429
+ print(f'{prefix}: {item["resolves"]}')
430
+ elif args.command == "list":
431
+ for item in list_items(args.root, args.workstream):
432
+ print(f'{item["id"]} {item["title"]}')
433
+ elif args.command == "active-plan":
434
+ plan, candidates = active_plan(args.root)
435
+ if plan is None:
436
+ if candidates:
437
+ print("caddis-todo: several current plans; name one:", file=sys.stderr)
438
+ for candidate in candidates:
439
+ print(f" {candidate}", file=sys.stderr)
440
+ else:
441
+ print("caddis-todo: no current plan in .caddis/plans", file=sys.stderr)
442
+ return caddis_exit.NOT_RUN
443
+ print(plan)
444
+ elif args.command == "reconcile":
445
+ reconciled = reconcile(args.root, args.session_state)
446
+ for rid in reconciled:
447
+ print(f"Reconciled: {rid}")
448
+ else:
449
+ rows = open_work(args.root, args.workstream)
450
+ if args.json:
451
+ print(json.dumps(rows, ensure_ascii=False))
452
+ else:
453
+ for row in rows:
454
+ print(f'{row["id"]} [{row["kind"]}] {row["title"]}')
455
+ return 0
456
+ except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
457
+ print(f"caddis-todo: {exc}", file=sys.stderr)
458
+ return 1
459
+
460
+
461
+ if __name__ == "__main__":
462
+ raise SystemExit(main())