@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,631 @@
1
+ """Local, independent workstream relays with compare-and-swap writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ try:
16
+ from caddis_file_lock import atomic_write, locked_path
17
+ from caddis_frontmatter import parse, split_document
18
+ except ModuleNotFoundError as exc:
19
+ if exc.name not in {"caddis_file_lock", "caddis_frontmatter"}:
20
+ raise
21
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
22
+ from caddis_file_lock import atomic_write, locked_path
23
+ from caddis_frontmatter import parse, split_document
24
+
25
+
26
+ _NAME = re.compile(r"(?:[a-z0-9][a-z0-9_-]{0,99}|branch(?:\.[a-z0-9_-]+)+(?:\+[0-9a-f]{12})?)\Z")
27
+ _CLEAN_BRANCH = re.compile(r"[a-z0-9_-]+(?:/[a-z0-9_-]+)*\Z")
28
+ _PLAIN_NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,99}\Z")
29
+ _BAD_REF = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]")
30
+ _NEXT = re.compile(r"^## Next step[^\n]*\n(.*?)(?=^## |\Z)", re.MULTILINE | re.DOTALL)
31
+ _MAX_INDEX = 800
32
+ _INDEX_MARKER = "<!-- caddis-workstream-index-v1 -->\n"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class WriteResult:
37
+ status: str
38
+ content_hash: str | None
39
+ diff: str = ""
40
+
41
+
42
+ def _name(value: str) -> str:
43
+ if (not isinstance(value, str) or not _NAME.fullmatch(value)
44
+ or value.upper() in {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}):
45
+ raise ValueError("workstream name must be a safe flat name")
46
+ return value
47
+
48
+
49
+ def _plain_name(value: str) -> str:
50
+ if not isinstance(value, str):
51
+ raise TypeError("plan or suggestion must be text")
52
+ name = _name(value.lower())
53
+ if not _PLAIN_NAME.fullmatch(name):
54
+ raise ValueError("plan or suggestion may not use the branch namespace")
55
+ return name
56
+
57
+
58
+ def _artifact(root: Path) -> Path:
59
+ project = Path(root).resolve()
60
+ artifact = Path(root) / ".caddis"
61
+ if not artifact.resolve().is_relative_to(project) or artifact.is_symlink():
62
+ raise ValueError("artifact root escapes project")
63
+ return artifact
64
+
65
+
66
+ def _branch(value: str) -> str:
67
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > 100 or value.startswith("-"):
68
+ raise ValueError("invalid branch name")
69
+ if (_BAD_REF.search(value) or value == "@" or "@{" in value or ".." in value
70
+ or "//" in value or value.startswith("/") or value.endswith(("/", "."))
71
+ or any(part.startswith(".") or part.endswith((".", ".lock")) for part in value.split("/"))):
72
+ raise ValueError("invalid Git branch name")
73
+ return value
74
+
75
+
76
+ def resolve_workstream(
77
+ plan_slug: str | None, branch: str | None, default_branch: str | None,
78
+ suggested_name: str | None,
79
+ ) -> str | None:
80
+ """Select the explicit plan, branch, or accepted suggestion, in that order.
81
+
82
+ Branch names stay readable for ``/catchup <name>``: ``feat/x`` becomes
83
+ ``branch.feat.x``. A branch with any other character (upper case, dots) is
84
+ slugged and gets ``+`` and 12 hex digits of its SHA-256. A slugged name never
85
+ equals a clean one (clean names contain no ``+``); two slugged names collide
86
+ only if their 48-bit digests do. A branch can never collide
87
+ with a plan/suggested name because those cannot contain the reserved ``.``.
88
+ """
89
+ if plan_slug:
90
+ return _plain_name(plan_slug)
91
+ if branch:
92
+ _branch(branch)
93
+ if default_branch is not None:
94
+ _branch(default_branch)
95
+ if branch != default_branch:
96
+ return _branch_name(branch)
97
+ return _plain_name(suggested_name) if suggested_name else None
98
+
99
+
100
+ def _branch_name(branch: str) -> str:
101
+ if _CLEAN_BRANCH.fullmatch(branch):
102
+ return _name("branch." + branch.replace("/", "."))
103
+ parts = (re.sub(r"[^a-z0-9_-]+", "-", part.lower()).strip("-") or "x" for part in branch.split("/"))
104
+ digest = hashlib.sha256(branch.encode("utf-8")).hexdigest()[:12]
105
+ return _name("branch." + ".".join(parts) + "+" + digest)
106
+
107
+
108
+ def _path(root: Path, name: str) -> tuple[Path, Path]:
109
+ artifact = _artifact(root)
110
+ return artifact, artifact / "relay" / (_name(name) + ".md")
111
+
112
+
113
+ def _hash(text: str) -> str:
114
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
115
+
116
+
117
+ def read_relay(root: Path, name: str) -> tuple[str, str | None]:
118
+ artifact, path = _path(root, name)
119
+ if not path.resolve().is_relative_to(artifact.resolve()) or path.is_symlink():
120
+ raise ValueError("relay path escapes artifact root")
121
+ try:
122
+ text = path.read_bytes().decode("utf-8")
123
+ except FileNotFoundError:
124
+ return "", None
125
+ return text, _hash(text)
126
+
127
+
128
+ def _validate_relay(name: str, text: str) -> None:
129
+ if not isinstance(text, str) or not text.startswith("---\n"):
130
+ raise ValueError("relay requires frontmatter")
131
+ if split_document(text)[1] == text: # no closing "---" line
132
+ raise ValueError("relay requires complete frontmatter")
133
+ metadata = parse(text)
134
+ if metadata.get("type") != "relay" or metadata.get("workstream") != name:
135
+ raise ValueError("relay type/workstream mismatch")
136
+ for field in ("branch", "session_id"):
137
+ if not isinstance(metadata.get(field), str) or not metadata[field]:
138
+ raise ValueError(f"relay requires {field}")
139
+ # A branch-only workstream has no plan: the key must exist, but may be empty or "none".
140
+ # The shared reader turns an empty "plan:" into {}.
141
+ plan = metadata.get("plan")
142
+ if not (isinstance(plan, str) or plan == {}):
143
+ raise ValueError("relay requires plan (empty when no plan)")
144
+ generated = metadata.get("generated")
145
+ if not isinstance(generated, dict) or not isinstance(generated.get("at"), str) or not generated["at"]:
146
+ raise ValueError("relay requires generated.at")
147
+ try:
148
+ datetime.fromisoformat(generated["at"].replace("Z", "+00:00"))
149
+ except ValueError as exc:
150
+ raise ValueError("relay requires ISO generated.at") from exc
151
+
152
+
153
+ def write_relay(root: Path, name: str, text: str, expected_hash: str | None) -> WriteResult:
154
+ """Return an explicit blocked result on conflict; never overwrite that file."""
155
+ artifact, path = _path(root, name)
156
+ _validate_relay(name, text)
157
+ if expected_hash is not None and not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
158
+ raise ValueError("expected_hash must be a SHA-256 hex digest or None")
159
+ with locked_path(path, root=artifact):
160
+ try:
161
+ current, actual_hash = read_relay(root, name)
162
+ except (UnicodeDecodeError, IsADirectoryError, PermissionError):
163
+ # Unreadable existing relay: never overwrite what we cannot show the caller.
164
+ return WriteResult("blocked", None, "Existing relay is unreadable; it was left untouched.")
165
+ if actual_hash != expected_hash:
166
+ diff = "".join(difflib.unified_diff(
167
+ current.splitlines(keepends=True), text.splitlines(keepends=True),
168
+ fromfile="current relay", tofile="caller relay",
169
+ ))
170
+ if not diff:
171
+ diff = "Content is identical, but the expected version does not match."
172
+ return WriteResult("blocked", actual_hash, diff)
173
+ atomic_write(path, text, root=artifact)
174
+ return WriteResult("written", _hash(text))
175
+
176
+
177
+ def _free_backup(path: Path) -> Path:
178
+ """``path`` if unused, else the first free ``<stem>.<n><suffix>``: a backup never overwrites one."""
179
+ if not os.path.lexists(path):
180
+ return path
181
+ for n in range(1, 1000):
182
+ candidate = path.with_name(f"{path.stem}.{n}{path.suffix}")
183
+ if not os.path.lexists(candidate):
184
+ return candidate
185
+ raise ValueError(f"too many backups next to {path.name}")
186
+
187
+
188
+ def _header_text(meta: dict) -> str:
189
+ """Relay header lines in the shape caddis_frontmatter.parse reads back: scalars, one nested
190
+ map or one list per key. Every value is folded to one line, so no value can add a field."""
191
+ def flat(value: object) -> str:
192
+ return " ".join(str(value).split())
193
+
194
+ lines = []
195
+ for key, value in meta.items():
196
+ if isinstance(value, dict):
197
+ lines.append(f"{key}:")
198
+ lines.extend(f" {sub}: {flat(val)}" for sub, val in value.items())
199
+ elif isinstance(value, list):
200
+ lines.append(f"{key}:")
201
+ lines.extend(f" - {flat(item)}" for item in value)
202
+ else:
203
+ lines.append(f"{key}: {flat(value)}")
204
+ return "\n".join(lines) + "\n"
205
+
206
+
207
+ def _single_line(text: str, limit: int) -> str:
208
+ clean = " ".join(text.split())
209
+ return clean if len(clean) <= limit else clean[:limit - 1].rstrip() + "…"
210
+
211
+
212
+ def _next_step(text: str) -> str:
213
+ match = _NEXT.search(text)
214
+ if not match:
215
+ return "next step unrecorded"
216
+ return _single_line(match.group(1), 120) or "next step unrecorded"
217
+
218
+
219
+ def _age(when: datetime, now: datetime) -> str:
220
+ seconds = max(0, int((now - when).total_seconds()))
221
+ if seconds < 3600:
222
+ return f"{seconds // 60}m"
223
+ if seconds < 86400:
224
+ return f"{seconds // 3600}h"
225
+ return f"{seconds // 86400}d"
226
+
227
+
228
+ def render_index(root: Path, session_id: str | None, branch: str | None) -> str:
229
+ """Write and return the bounded startup index from current local files."""
230
+ artifact = _artifact(root)
231
+ index = artifact / "relay.md"
232
+ now = datetime.now(timezone.utc)
233
+ if index.is_symlink(): # locked_path refuses this too; stated here so the rule is local
234
+ raise ValueError("refusing symlinked relay index")
235
+ with locked_path(index, root=artifact):
236
+ if index.exists() and not index.read_text(encoding="utf-8-sig").startswith(_INDEX_MARKER):
237
+ raise ValueError("legacy relay.md must be migrated before index generation")
238
+ rows: list[tuple[float, str]] = []
239
+ claimed_sessions: set[str] = set()
240
+ directory = artifact / "relay"
241
+ if directory.exists() and not directory.resolve().is_relative_to(artifact.resolve()):
242
+ raise ValueError("relay directory escapes artifact root")
243
+ for path in directory.glob("*.md") if directory.is_dir() else ():
244
+ if path.is_symlink():
245
+ continue
246
+ try:
247
+ name = _name(path.stem)
248
+ content, _ = read_relay(root, name)
249
+ meta = parse(content)
250
+ if meta.get("type") != "relay" or meta.get("workstream") != name:
251
+ continue
252
+ generated = meta.get("generated")
253
+ at = datetime.fromisoformat(generated["at"].replace("Z", "+00:00"))
254
+ if at.tzinfo is None:
255
+ at = at.replace(tzinfo=timezone.utc)
256
+ claimed_sessions.add(str(meta.get("session_id", "")))
257
+ label = _single_line(name, 80)
258
+ branch_label = _single_line(str(meta.get("branch", "?")), 50)
259
+ rows.append((at.timestamp(), f"- {label} ({branch_label}, {_age(at, now)}): {_next_step(content)}"))
260
+ except (ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
261
+ continue
262
+ states = artifact / "session-state"
263
+ if states.exists() and not states.resolve().is_relative_to(artifact.resolve()):
264
+ raise ValueError("session-state directory escapes artifact root")
265
+ for path in states.glob("*.md") if states.is_dir() else ():
266
+ if path.is_symlink() or path.stem in claimed_sessions:
267
+ continue
268
+ try:
269
+ modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
270
+ if (now - modified).total_seconds() > 7 * 86400:
271
+ continue
272
+ content = path.read_text(encoding="utf-8")
273
+ request = re.search(r"^## Last thing you asked\s*\n\s*>\s*(.+)$", content, re.MULTILINE)
274
+ state_branch = re.search(r"\*\*Branch:\*\*[ \t]*([^·\n]+)", content)
275
+ label = _single_line(path.stem, 60)
276
+ # A system notice (e.g. "<task-notification>") is not something the user asked.
277
+ asked = request.group(1).strip() if request else ""
278
+ note = _single_line(asked, 80) if asked and not asked.startswith("<") else "recent session"
279
+ branch_label = _single_line(state_branch.group(1), 40) if state_branch else "unknown branch"
280
+ rows.append((modified.timestamp(), f"- unnamed session {label} ({branch_label}, {_age(modified, now)}): {note}"))
281
+ except (OSError, UnicodeError):
282
+ continue
283
+ rows.sort(key=lambda item: (-item[0], item[1]))
284
+ heading = _INDEX_MARKER + "# Workstream index\n"
285
+ if session_id or branch:
286
+ heading += f"Session: {_single_line(session_id or '?', 50)} · branch: {_single_line(branch or '?', 50)}\n"
287
+ prompt = "\nRun /catchup <name> to read one workstream.\n"
288
+ selected: list[str] = []
289
+ for _, row in rows:
290
+ omitted = len(rows) - len(selected) - 1
291
+ suffix = f"… {omitted} more omitted.\n" if omitted else ""
292
+ candidate = heading + "\n" + "\n".join(selected + [row]) + "\n" + suffix + prompt
293
+ if len(candidate) > _MAX_INDEX:
294
+ break
295
+ selected.append(row)
296
+ omitted = len(rows) - len(selected)
297
+ body = "\n".join(selected) if selected else ("No workstream fits the budget." if rows else "No named workstreams.")
298
+ suffix = f"\n… {omitted} more omitted." if omitted else ""
299
+ result = heading + "\n" + body + suffix + prompt
300
+ atomic_write(index, result, root=artifact)
301
+ return result
302
+
303
+
304
+ class MigrationReport(dict):
305
+ """Report describing the outcome of a legacy migration."""
306
+
307
+ @property
308
+ def status(self) -> str:
309
+ return self["status"]
310
+
311
+ @property
312
+ def migrated(self) -> list[str]:
313
+ return self["migrated"]
314
+
315
+ @property
316
+ def conflicts(self) -> list[str]:
317
+ return self["conflicts"]
318
+
319
+ @property
320
+ def backups(self) -> list[str]:
321
+ return self["backups"]
322
+
323
+
324
+ def migrate_legacy(root: Path) -> MigrationReport:
325
+ """Run the locked migration, then make sure an index exists when there was nothing to do.
326
+
327
+ A previous run (or a racing session) may have removed the legacy files and then failed
328
+ to write the index. render_index takes the index lock itself, so this check runs here,
329
+ after the migration has released that lock.
330
+ """
331
+ report = _migrate_legacy_locked(root)
332
+ if report.status == "clean":
333
+ artifact = _artifact(root)
334
+ relay_dir = artifact / "relay"
335
+ if (not os.path.lexists(artifact / "relay.md") and relay_dir.is_dir()
336
+ and any(relay_dir.glob("*.md"))):
337
+ render_index(root, None, None)
338
+ return report
339
+
340
+
341
+ def _migrate_legacy_locked(root: Path) -> MigrationReport:
342
+ """Migrate legacy workstreams.json and single relay.md into per-topic relays.
343
+
344
+ Locked and read-once. Leaves local .bak backups of migrated files.
345
+ Aborts on conflicting target relays and preserves the old relay until all imports validate.
346
+ """
347
+ artifact = _artifact(root)
348
+ legacy_ws = artifact / "workstreams.json"
349
+ index_file = artifact / "relay.md"
350
+ relay_dir = artifact / "relay"
351
+
352
+ has_legacy_ws = legacy_ws.is_file() and not legacy_ws.is_symlink()
353
+ has_legacy_relay = False
354
+ if index_file.is_file() and not index_file.is_symlink():
355
+ try:
356
+ head = index_file.read_text(encoding="utf-8-sig", errors="ignore")
357
+ has_legacy_relay = not head.startswith(_INDEX_MARKER)
358
+ except OSError:
359
+ has_legacy_relay = False
360
+
361
+ if not has_legacy_ws and not has_legacy_relay:
362
+ return MigrationReport(status="clean", migrated=[], conflicts=[], backups=[])
363
+
364
+ with locked_path(index_file, root=artifact):
365
+ # Re-check within lock
366
+ has_legacy_ws = legacy_ws.is_file() and not legacy_ws.is_symlink()
367
+ has_legacy_relay = False
368
+ if index_file.is_file() and not index_file.is_symlink():
369
+ try:
370
+ head = index_file.read_text(encoding="utf-8-sig", errors="ignore")
371
+ has_legacy_relay = not head.startswith(_INDEX_MARKER)
372
+ except OSError:
373
+ has_legacy_relay = False
374
+
375
+ if not has_legacy_ws and not has_legacy_relay:
376
+ return MigrationReport(status="clean", migrated=[], conflicts=[], backups=[])
377
+
378
+ candidates: dict[str, str] = {}
379
+ conflicts: list[str] = []
380
+
381
+ # 1. Process legacy relay.md
382
+ if has_legacy_relay:
383
+ try:
384
+ raw_relay = index_file.read_text(encoding="utf-8-sig") # a BOM must not hide the header
385
+ except UnicodeDecodeError:
386
+ # Report, never guess: a lossy decode would migrate damaged text.
387
+ return MigrationReport(status="conflict", migrated=[],
388
+ conflicts=["legacy-relay-not-utf8"], backups=[])
389
+ relay_meta = parse(raw_relay)
390
+ relay_name = relay_meta.get("workstream")
391
+ if not relay_name:
392
+ plan_val = relay_meta.get("plan")
393
+ if isinstance(plan_val, str) and plan_val:
394
+ relay_name = Path(plan_val.replace("\\", "/")).name.removesuffix(".md")
395
+ else:
396
+ relay_name = "legacy"
397
+ try:
398
+ relay_name = _name(str(relay_name).lower())
399
+ except ValueError:
400
+ relay_name = "legacy"
401
+
402
+ candidate_text = raw_relay
403
+ # No header, or an opening --- with no closing one: keep the whole file as the body.
404
+ header_closed = split_document(candidate_text)[1] != candidate_text
405
+ if not candidate_text.startswith("---\n") or not header_closed:
406
+ now_iso = datetime.now(timezone.utc).isoformat()
407
+ candidate_text = (
408
+ f"---\ntype: relay\nworkstream: {relay_name}\nbranch: main\n"
409
+ f"session_id: legacy-migrated\nplan:\ngenerated:\n at: {now_iso}\n---\n\n"
410
+ f"# Relay — {relay_name}\n\n## Next step (exact)\n{raw_relay}\n"
411
+ )
412
+ else:
413
+ meta = parse(candidate_text)
414
+ modified = False
415
+ if meta.get("type") != "relay":
416
+ meta["type"] = "relay"
417
+ modified = True
418
+ if meta.get("workstream") != relay_name: # e.g. "Alpha" is stored as alpha.md
419
+ meta["workstream"] = relay_name
420
+ modified = True
421
+ if not meta.get("branch"):
422
+ meta["branch"] = "main"
423
+ modified = True
424
+ if not meta.get("session_id"):
425
+ meta["session_id"] = "legacy-migrated"
426
+ modified = True
427
+ if "plan" not in meta:
428
+ meta["plan"] = ""
429
+ modified = True
430
+ if not isinstance(meta.get("generated"), dict) or not meta["generated"].get("at"):
431
+ # Keep the original time from an inline "{ by: x, at: y }" map; else now.
432
+ inline = re.search(r"\bat:\s*([0-9][0-9T:.+\-Z]+)", str(meta.get("generated", "")))
433
+ meta["generated"] = {"at": inline.group(1) if inline else datetime.now(timezone.utc).isoformat()}
434
+ modified = True
435
+ if modified:
436
+ # Plain writer: the plugin ships no YAML library, and this runs in user installs.
437
+ body = split_document(candidate_text)[1].lstrip("\n")
438
+ candidate_text = "---\n" + _header_text(meta) + "---\n\n" + body
439
+
440
+ try:
441
+ _validate_relay(relay_name, candidate_text)
442
+ except Exception as exc:
443
+ return MigrationReport(status="conflict", migrated=[], conflicts=[f"invalid-legacy-relay: {exc}"], backups=[])
444
+
445
+ target_path = relay_dir / f"{_name(relay_name)}.md"
446
+ if target_path.is_file() and not target_path.is_symlink():
447
+ # Unreadable bytes compare unequal, so a corrupt target is a conflict, never overwritten.
448
+ existing_text = target_path.read_text(encoding="utf-8", errors="replace")
449
+ if existing_text.strip() != candidate_text.strip():
450
+ conflicts.append(relay_name)
451
+ if relay_name not in conflicts:
452
+ candidates[relay_name] = candidate_text
453
+
454
+ # 2. Process legacy workstreams.json
455
+ if has_legacy_ws:
456
+ try:
457
+ ws_data = json.loads(legacy_ws.read_text(encoding="utf-8-sig"))
458
+ except Exception as exc:
459
+ return MigrationReport(status="conflict", migrated=[], conflicts=[f"invalid-workstreams-json: {exc}"], backups=[])
460
+ if not isinstance(ws_data, dict):
461
+ return MigrationReport(status="conflict", migrated=[], conflicts=["workstreams-json-not-dict"], backups=[])
462
+ if "stack" not in ws_data:
463
+ return MigrationReport(status="conflict", migrated=[],
464
+ conflicts=["workstreams-json-has-no-stack"], backups=[])
465
+ stack = ws_data["stack"]
466
+ if not isinstance(stack, list):
467
+ return MigrationReport(status="conflict", migrated=[], conflicts=["workstreams-stack-not-list"], backups=[])
468
+
469
+ for frame in stack:
470
+ if not isinstance(frame, dict):
471
+ continue
472
+ plan = frame.get("plan")
473
+ if plan:
474
+ stem = Path(str(plan).replace("\\", "/")).name.removesuffix(".md")
475
+ else:
476
+ stem = "legacy"
477
+ try:
478
+ frame_name = _name(stem.lower())
479
+ except ValueError:
480
+ frame_name = "legacy"
481
+
482
+ branch = frame.get("branch") or "main"
483
+ session_id = frame.get("session_id") or "legacy-migrated"
484
+ resume = frame.get("resumePointer") or "next step unrecorded"
485
+ pushed = frame.get("pushedAt") or datetime.now(timezone.utc).isoformat()
486
+ try:
487
+ datetime.fromisoformat(str(pushed).replace("Z", "+00:00"))
488
+ except ValueError:
489
+ pushed = datetime.now(timezone.utc).isoformat()
490
+
491
+ # _header_text folds every value to one line: a newline in workstreams.json
492
+ # must not add or break header fields.
493
+ header = _header_text({"type": "relay", "workstream": frame_name, "branch": str(branch),
494
+ "session_id": str(session_id), "plan": str(plan or ""),
495
+ "generated": {"at": str(pushed)}})
496
+ candidate_text = (f"---\n{header}---\n\n# Relay — {frame_name}\n\n"
497
+ f"## Next step (exact)\n{' '.join(str(resume).split())}\n")
498
+ try:
499
+ _validate_relay(frame_name, candidate_text)
500
+ except Exception as exc:
501
+ conflicts.append(f"invalid-frame-{frame_name}: {exc}")
502
+ continue
503
+
504
+ target_path = relay_dir / f"{_name(frame_name)}.md"
505
+ if target_path.is_file() and not target_path.is_symlink():
506
+ # Unreadable bytes compare unequal, so a corrupt target is a conflict, never overwritten.
507
+ existing_text = target_path.read_text(encoding="utf-8", errors="replace")
508
+ if existing_text.strip() != candidate_text.strip():
509
+ conflicts.append(frame_name)
510
+ if frame_name in candidates and candidates[frame_name].strip() != candidate_text.strip():
511
+ conflicts.append(frame_name)
512
+ if frame_name not in conflicts:
513
+ candidates[frame_name] = candidate_text
514
+
515
+ # Abort on conflicting target relays
516
+ if conflicts:
517
+ return MigrationReport(status="conflict", migrated=[], conflicts=sorted(set(conflicts)), backups=[])
518
+
519
+ # Write candidates
520
+ relay_dir.mkdir(parents=True, exist_ok=True)
521
+ migrated: list[str] = []
522
+ for name, text in candidates.items():
523
+ # Locked compare-and-swap on the target: a session that wrote this relay after the
524
+ # conflict check above wins, and the legacy files are kept for a re-run.
525
+ # Expect "absent": the conflict check above only let absent or identical targets through.
526
+ try:
527
+ current, current_hash = read_relay(root, name)
528
+ if current_hash is not None and current.strip() == text.strip():
529
+ migrated.append(name) # already identical: nothing to write
530
+ continue
531
+ written = write_relay(root, name, text, None).status == "written"
532
+ except (OSError, ValueError) as exc:
533
+ return MigrationReport(status="conflict", migrated=migrated,
534
+ conflicts=[f"{name}: cannot write relay ({type(exc).__name__})"],
535
+ backups=[])
536
+ if not written:
537
+ return MigrationReport(status="conflict", migrated=migrated,
538
+ conflicts=[f"{name}: written by another session during migration"],
539
+ backups=[])
540
+ migrated.append(name)
541
+
542
+ backups: list[str] = []
543
+ if has_legacy_ws:
544
+ bak_path = _free_backup(artifact / "workstreams.json.bak")
545
+ bak_path.write_bytes(legacy_ws.read_bytes())
546
+ legacy_ws.unlink()
547
+ backups.append(bak_path.name)
548
+
549
+ if has_legacy_relay:
550
+ bak_path = _free_backup(artifact / "relay.md.bak")
551
+ bak_path.write_bytes(index_file.read_bytes())
552
+ index_file.unlink()
553
+ backups.append(bak_path.name)
554
+
555
+ # Render index outside the lock so render_index can acquire its own lock
556
+ render_index(root, None, None)
557
+ return MigrationReport(status="migrated", migrated=migrated, conflicts=[], backups=backups)
558
+
559
+
560
+ def main(argv: list[str] | None = None) -> int:
561
+ import argparse
562
+ parser = argparse.ArgumentParser(description="Workstream relay management")
563
+ sub = parser.add_subparsers(dest="command")
564
+
565
+ resolve_p = sub.add_parser("resolve", help="print the workstream name, or nothing")
566
+ resolve_p.add_argument("--plan")
567
+ resolve_p.add_argument("--branch")
568
+ resolve_p.add_argument("--default-branch")
569
+ resolve_p.add_argument("--suggest")
570
+
571
+ read_p = sub.add_parser("read")
572
+ read_p.add_argument("name")
573
+ read_p.add_argument("--root", type=Path, default=Path("."))
574
+
575
+ hash_p = sub.add_parser("hash", help="print the stored relay's hash, or 'none' when absent")
576
+ hash_p.add_argument("name")
577
+ hash_p.add_argument("--root", type=Path, default=Path("."))
578
+
579
+ write_p = sub.add_parser("write")
580
+ write_p.add_argument("name")
581
+ write_p.add_argument("file", type=Path)
582
+ write_p.add_argument("--expected-hash", help="hash from `hash`; 'none' when the relay must not exist yet")
583
+ write_p.add_argument("--root", type=Path, default=Path("."))
584
+
585
+ render_p = sub.add_parser("render-index")
586
+ render_p.add_argument("--session-id")
587
+ render_p.add_argument("--branch")
588
+ render_p.add_argument("--root", type=Path, default=Path("."))
589
+
590
+ mig_p = sub.add_parser("migrate-legacy")
591
+ mig_p.add_argument("--root", type=Path, default=Path("."))
592
+
593
+ args = parser.parse_args(argv)
594
+ if args.command == "resolve":
595
+ name = resolve_workstream(args.plan, args.branch, args.default_branch, args.suggest)
596
+ if name:
597
+ print(name)
598
+ return 0
599
+ if args.command == "read":
600
+ text, _ = read_relay(args.root, args.name)
601
+ sys.stdout.write(text)
602
+ return 0
603
+ elif args.command == "hash":
604
+ print(read_relay(args.root, args.name)[1] or "none")
605
+ return 0
606
+ elif args.command == "write":
607
+ content = args.file.read_text(encoding="utf-8")
608
+ expected = None if args.expected_hash in (None, "none") else args.expected_hash
609
+ res = write_relay(args.root, args.name, content, expected)
610
+ if res.status != "written":
611
+ # The caller must see what changed; nothing was written.
612
+ sys.stdout.write(res.diff + ("\n" if not res.diff.endswith("\n") else ""))
613
+ sys.stderr.write("relay not written: the stored relay changed since you read it (diff above)\n")
614
+ return 1
615
+ print(res.content_hash)
616
+ return 0
617
+ elif args.command == "render-index":
618
+ render_index(args.root, args.session_id, args.branch)
619
+ return 0
620
+ elif args.command == "migrate-legacy":
621
+ report = migrate_legacy(args.root)
622
+ print(f"Migration status: {report.status}")
623
+ for conflict in report.conflicts:
624
+ print(f" conflict: {conflict}")
625
+ return 0 if report.status in ("clean", "migrated") else 1
626
+ parser.print_help()
627
+ return 2
628
+
629
+
630
+ if __name__ == "__main__":
631
+ sys.exit(main())