@caddis/cli 0.4.34 → 0.4.35
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/agents/code-reviewer.md +4 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +185 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
- package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
- package/bundles/antigravity-plugin/session_end_agy.py +16 -1
- package/bundles/antigravity-plugin/skills/_registry.md +34 -32
- package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
- package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
- package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
- package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +5 -0
- package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +21 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
- package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +15 -0
- package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
- package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +19 -4
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -0
- package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
- package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
- package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
- package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
- package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
- package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
- package/bundles/codex/AGENTS.md +5 -1
- package/bundles/manifest.json +3 -3
- 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())
|