canopy-cli 3.1.0__py3-none-any.whl
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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Cross-session feature memory (M4).
|
|
2
|
+
|
|
3
|
+
Per-feature markdown file at ``<workspace>/.canopy/memory/<feature>.md``
|
|
4
|
+
that captures decisions, events, comment activity, and PR context across
|
|
5
|
+
agent sessions. Auto-read by ``canopy switch`` so a fresh agent picks up
|
|
6
|
+
where the last one left off.
|
|
7
|
+
|
|
8
|
+
Three top-level sections (newest content first within each):
|
|
9
|
+
|
|
10
|
+
1. **Resolutions log** — per-comment outcomes; ``✓`` resolved, ``⊙`` likely-
|
|
11
|
+
resolved by classifier, ``⚠`` unresolved, ``⊘`` deferred. Never
|
|
12
|
+
compacted (the always-current source of truth for review state).
|
|
13
|
+
2. **PR context** — one block per PR opened against the feature, plus
|
|
14
|
+
per-PR update entries. Never compacted.
|
|
15
|
+
3. **Sessions** — per-session narrative entries. The only section that
|
|
16
|
+
gets compacted on switch-away.
|
|
17
|
+
|
|
18
|
+
API contract: every record function appends a structured entry; reads
|
|
19
|
+
return either raw structured entries (for tests / extensions) or rendered
|
|
20
|
+
markdown (for the agent / dashboard). Storage is line-delimited JSON
|
|
21
|
+
under the hood, rendered to markdown on demand. This keeps writes O(1)
|
|
22
|
+
and lets the rendering layer evolve without a data migration.
|
|
23
|
+
|
|
24
|
+
File concurrency: writes use ``fcntl.flock`` with the same pattern as
|
|
25
|
+
``.canopy/state/heads.json`` so concurrent agents on the same feature
|
|
26
|
+
across worktrees don't corrupt the log.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import fcntl
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import tempfile
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Iterable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_MEMORY_DIR = ".canopy/memory"
|
|
41
|
+
|
|
42
|
+
# Storage is JSONL; the public surface is the rendered .md. We keep both
|
|
43
|
+
# alongside each other so external tools can grep the markdown while the
|
|
44
|
+
# write path stays append-only.
|
|
45
|
+
_STORE_SUFFIX = ".jsonl"
|
|
46
|
+
_RENDER_SUFFIX = ".md"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Paths ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _memory_dir(workspace_root: Path) -> Path:
|
|
53
|
+
return workspace_root / _MEMORY_DIR
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def store_path(workspace_root: Path, feature: str) -> Path:
|
|
57
|
+
"""Append-only JSONL store for the feature's memory entries."""
|
|
58
|
+
return _memory_dir(workspace_root) / f"{feature}{_STORE_SUFFIX}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_path(workspace_root: Path, feature: str) -> Path:
|
|
62
|
+
"""Rendered markdown view written alongside the store."""
|
|
63
|
+
return _memory_dir(workspace_root) / f"{feature}{_RENDER_SUFFIX}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now_iso() -> str:
|
|
67
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Locking + atomic write helpers ──────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextmanager
|
|
74
|
+
def _locked_append(path: Path):
|
|
75
|
+
"""Append-mode file handle with an exclusive flock.
|
|
76
|
+
|
|
77
|
+
Same pattern the post-checkout hook uses for heads.json — concurrent
|
|
78
|
+
agents writing to the same feature's memory queue safely. The first
|
|
79
|
+
write into the memory directory drops a ``.gitignore`` so the
|
|
80
|
+
per-feature memory files don't accidentally get committed.
|
|
81
|
+
"""
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
_ensure_memory_gitignore(path.parent)
|
|
84
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
85
|
+
try:
|
|
86
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
87
|
+
yield f
|
|
88
|
+
finally:
|
|
89
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ensure_memory_gitignore(memory_dir: Path) -> None:
|
|
93
|
+
"""Drop a ``.gitignore`` that ignores everything under .canopy/memory/.
|
|
94
|
+
|
|
95
|
+
Memory files are local working state — useful to the agent on this
|
|
96
|
+
machine, not something to commit to the workspace's repos. The
|
|
97
|
+
.gitignore itself stays tracked so the policy is visible in the diff.
|
|
98
|
+
"""
|
|
99
|
+
gi = memory_dir / ".gitignore"
|
|
100
|
+
if gi.exists():
|
|
101
|
+
return
|
|
102
|
+
gi.write_text("# Auto-written by canopy historian (M4).\n*\n!.gitignore\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
fd, tmp = tempfile.mkstemp(
|
|
108
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent),
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
112
|
+
f.write(text)
|
|
113
|
+
os.replace(tmp, path)
|
|
114
|
+
except Exception:
|
|
115
|
+
try:
|
|
116
|
+
os.unlink(tmp)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
pass
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Append + load primitives ────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _append_entry(workspace_root: Path, feature: str, entry: dict[str, Any]) -> None:
|
|
126
|
+
entry.setdefault("at", _now_iso())
|
|
127
|
+
line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
|
128
|
+
with _locked_append(store_path(workspace_root, feature)) as f:
|
|
129
|
+
f.write(line + "\n")
|
|
130
|
+
# Re-render the markdown view so external readers see fresh state.
|
|
131
|
+
_refresh_render(workspace_root, feature)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_entries(workspace_root: Path, feature: str) -> list[dict[str, Any]]:
|
|
135
|
+
path = store_path(workspace_root, feature)
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return []
|
|
138
|
+
out: list[dict[str, Any]] = []
|
|
139
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
140
|
+
for line in f:
|
|
141
|
+
line = line.rstrip("\n")
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
out.append(json.loads(line))
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
continue
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Public record API ───────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def record_decision(
|
|
155
|
+
workspace_root: Path, feature: str, *,
|
|
156
|
+
title: str, rationale: str = "", at: str | None = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Capture a decision the agent made (e.g. choosing one library over another).
|
|
159
|
+
|
|
160
|
+
Decisions are deduplicated by ``title`` within the most-recent session
|
|
161
|
+
so the hybrid capture mechanism (explicit tool call + Stop-hook
|
|
162
|
+
tail-parse) doesn't double-log.
|
|
163
|
+
"""
|
|
164
|
+
entry = {
|
|
165
|
+
"kind": "decision", "title": title, "rationale": rationale,
|
|
166
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
167
|
+
}
|
|
168
|
+
if _decision_already_logged(workspace_root, feature, title, entry["session"]):
|
|
169
|
+
return {"action": "deduped", "title": title}
|
|
170
|
+
_append_entry(workspace_root, feature, entry)
|
|
171
|
+
return {"action": "recorded", "title": title}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def record_event(
|
|
175
|
+
workspace_root: Path, feature: str, *,
|
|
176
|
+
summary: str, kind: str = "event", at: str | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""One-line summary of a tool invocation (Edit, Bash, preflight, etc.).
|
|
179
|
+
|
|
180
|
+
The ``kind`` field lets later renderers group events by type
|
|
181
|
+
(e.g. "edited" vs "ran" vs "preflight"). Defaults to ``event``.
|
|
182
|
+
"""
|
|
183
|
+
entry = {
|
|
184
|
+
"kind": kind, "summary": summary,
|
|
185
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
186
|
+
}
|
|
187
|
+
_append_entry(workspace_root, feature, entry)
|
|
188
|
+
return {"action": "recorded", "summary": summary}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def record_pause(
|
|
192
|
+
workspace_root: Path, feature: str, *,
|
|
193
|
+
reason: str, at: str | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Capture why the agent stopped — what's blocked, what's needed next."""
|
|
196
|
+
entry = {
|
|
197
|
+
"kind": "pause", "reason": reason,
|
|
198
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
199
|
+
}
|
|
200
|
+
_append_entry(workspace_root, feature, entry)
|
|
201
|
+
return {"action": "recorded"}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def record_comment_read(
|
|
205
|
+
workspace_root: Path, feature: str, *,
|
|
206
|
+
comment_id: str | int, author: str, path: str, line: int = 0,
|
|
207
|
+
body_excerpt: str = "", url: str = "", at: str | None = None,
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Log that the agent read a specific comment. Deduped per-session by id."""
|
|
210
|
+
cid = str(comment_id)
|
|
211
|
+
if _comment_read_already_logged(workspace_root, feature, cid, _current_session_id()):
|
|
212
|
+
return {"action": "deduped", "comment_id": cid}
|
|
213
|
+
entry = {
|
|
214
|
+
"kind": "comment_read", "comment_id": cid, "author": author,
|
|
215
|
+
"path": path, "line": line, "body_excerpt": body_excerpt, "url": url,
|
|
216
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
217
|
+
}
|
|
218
|
+
_append_entry(workspace_root, feature, entry)
|
|
219
|
+
return {"action": "recorded", "comment_id": cid}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def record_comment_resolved(
|
|
223
|
+
workspace_root: Path, feature: str, *,
|
|
224
|
+
comment_id: str | int, author: str = "", path: str = "", line: int = 0,
|
|
225
|
+
commit_sha: str, gist: str = "", url: str = "", at: str | None = None,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Log that a comment was addressed by a specific commit."""
|
|
228
|
+
entry = {
|
|
229
|
+
"kind": "comment_resolved", "comment_id": str(comment_id),
|
|
230
|
+
"author": author, "path": path, "line": line,
|
|
231
|
+
"commit_sha": commit_sha, "gist": gist, "url": url,
|
|
232
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
233
|
+
}
|
|
234
|
+
_append_entry(workspace_root, feature, entry)
|
|
235
|
+
return {"action": "recorded", "comment_id": str(comment_id)}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def record_comment_deferred(
|
|
239
|
+
workspace_root: Path, feature: str, *,
|
|
240
|
+
comment_id: str | int, reason: str, author: str = "", path: str = "",
|
|
241
|
+
line: int = 0, url: str = "", at: str | None = None,
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
"""Log a comment the user / agent intentionally deferred."""
|
|
244
|
+
entry = {
|
|
245
|
+
"kind": "comment_deferred", "comment_id": str(comment_id),
|
|
246
|
+
"reason": reason, "author": author, "path": path, "line": line,
|
|
247
|
+
"url": url, "at": at or _now_iso(), "session": _current_session_id(),
|
|
248
|
+
}
|
|
249
|
+
_append_entry(workspace_root, feature, entry)
|
|
250
|
+
return {"action": "recorded", "comment_id": str(comment_id)}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def record_classifier_resolved(
|
|
254
|
+
workspace_root: Path, feature: str, *,
|
|
255
|
+
threads: list[dict], at: str | None = None,
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
"""Log the temporal classifier's likely-resolved set (one batch per session)."""
|
|
258
|
+
if not threads:
|
|
259
|
+
return {"action": "noop"}
|
|
260
|
+
if _classifier_already_logged(workspace_root, feature, _current_session_id()):
|
|
261
|
+
return {"action": "deduped"}
|
|
262
|
+
entry = {
|
|
263
|
+
"kind": "classifier_resolved", "threads": threads,
|
|
264
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
265
|
+
}
|
|
266
|
+
_append_entry(workspace_root, feature, entry)
|
|
267
|
+
return {"action": "recorded", "count": len(threads)}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def record_pr_context(
|
|
271
|
+
workspace_root: Path, feature: str, *,
|
|
272
|
+
pr_number: int, repo: str, title: str, base: str = "main",
|
|
273
|
+
rationale: str = "", url: str = "", at: str | None = None,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Log when a PR is opened for the feature."""
|
|
276
|
+
entry = {
|
|
277
|
+
"kind": "pr_context", "pr_number": pr_number, "repo": repo,
|
|
278
|
+
"title": title, "base": base, "rationale": rationale, "url": url,
|
|
279
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
280
|
+
}
|
|
281
|
+
_append_entry(workspace_root, feature, entry)
|
|
282
|
+
return {"action": "recorded", "pr_number": pr_number}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def record_pr_update(
|
|
286
|
+
workspace_root: Path, feature: str, *,
|
|
287
|
+
pr_number: int, repo: str, summary: str, at: str | None = None,
|
|
288
|
+
) -> dict[str, Any]:
|
|
289
|
+
"""Log an update pushed to an existing PR."""
|
|
290
|
+
entry = {
|
|
291
|
+
"kind": "pr_update", "pr_number": pr_number, "repo": repo,
|
|
292
|
+
"summary": summary,
|
|
293
|
+
"at": at or _now_iso(), "session": _current_session_id(),
|
|
294
|
+
}
|
|
295
|
+
_append_entry(workspace_root, feature, entry)
|
|
296
|
+
return {"action": "recorded", "pr_number": pr_number}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ── Read API ────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def read(workspace_root: Path, feature: str) -> list[dict[str, Any]]:
|
|
303
|
+
"""Return the raw entries (oldest → newest)."""
|
|
304
|
+
return _load_entries(workspace_root, feature)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def format_for_agent(workspace_root: Path, feature: str) -> str:
|
|
308
|
+
"""Render the memory as markdown for inclusion in switch responses.
|
|
309
|
+
|
|
310
|
+
Returns an empty string when no memory exists yet (so callers can
|
|
311
|
+
cheaply check truthiness before embedding).
|
|
312
|
+
"""
|
|
313
|
+
entries = _load_entries(workspace_root, feature)
|
|
314
|
+
if not entries:
|
|
315
|
+
return ""
|
|
316
|
+
return _render(feature, entries)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def format_for_agent_since(
|
|
320
|
+
workspace_root: Path, feature: str, since_iso: str,
|
|
321
|
+
) -> str:
|
|
322
|
+
"""Render only entries with timestamp > since_iso.
|
|
323
|
+
|
|
324
|
+
Returns an empty string when no entries match the filter or the feature
|
|
325
|
+
has no memory yet. Timestamps are compared lexicographically, so
|
|
326
|
+
since_iso must be in ISO 8601 Z format (e.g. "2026-05-26T15:30:00Z").
|
|
327
|
+
"""
|
|
328
|
+
entries = _load_entries(workspace_root, feature)
|
|
329
|
+
if not entries:
|
|
330
|
+
return ""
|
|
331
|
+
filtered = [e for e in entries if e.get("at", "") > since_iso]
|
|
332
|
+
if not filtered:
|
|
333
|
+
return ""
|
|
334
|
+
return _render(feature, filtered)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── Compaction ──────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def compact(
|
|
341
|
+
workspace_root: Path, feature: str, *, keep_sessions: int = 5,
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
"""Trim the Sessions section to the most-recent ``keep_sessions``.
|
|
344
|
+
|
|
345
|
+
v1 deliberately avoids an LLM call — it just drops session entries
|
|
346
|
+
older than the cutoff. The Resolutions log + PR context entries are
|
|
347
|
+
always preserved, regardless of session age. The plan reserves a
|
|
348
|
+
future LLM-based summarization pass; until then this keeps the file
|
|
349
|
+
bounded without losing structured state.
|
|
350
|
+
"""
|
|
351
|
+
entries = _load_entries(workspace_root, feature)
|
|
352
|
+
if not entries:
|
|
353
|
+
return {"action": "noop", "reason": "no memory file"}
|
|
354
|
+
|
|
355
|
+
sessions_seen: list[str] = []
|
|
356
|
+
for e in reversed(entries):
|
|
357
|
+
s = e.get("session")
|
|
358
|
+
if s and s not in sessions_seen:
|
|
359
|
+
sessions_seen.append(s)
|
|
360
|
+
if len(sessions_seen) > keep_sessions:
|
|
361
|
+
break
|
|
362
|
+
|
|
363
|
+
if len(sessions_seen) <= keep_sessions:
|
|
364
|
+
return {"action": "noop", "reason": "already within keep_sessions"}
|
|
365
|
+
|
|
366
|
+
keep_ids = set(sessions_seen[:keep_sessions])
|
|
367
|
+
structural_kinds = {
|
|
368
|
+
"comment_resolved", "comment_deferred", "classifier_resolved",
|
|
369
|
+
"pr_context", "pr_update",
|
|
370
|
+
}
|
|
371
|
+
kept = [
|
|
372
|
+
e for e in entries
|
|
373
|
+
if e.get("kind") in structural_kinds
|
|
374
|
+
or e.get("session") in keep_ids
|
|
375
|
+
or e.get("session") is None # legacy entries without session
|
|
376
|
+
]
|
|
377
|
+
dropped = len(entries) - len(kept)
|
|
378
|
+
|
|
379
|
+
# Rewrite the JSONL store atomically.
|
|
380
|
+
text = "\n".join(
|
|
381
|
+
json.dumps(e, sort_keys=True, ensure_ascii=False) for e in kept
|
|
382
|
+
)
|
|
383
|
+
if text:
|
|
384
|
+
text += "\n"
|
|
385
|
+
_atomic_write(store_path(workspace_root, feature), text)
|
|
386
|
+
_refresh_render(workspace_root, feature)
|
|
387
|
+
return {"action": "compacted", "kept": len(kept), "dropped": dropped}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ── Internals ───────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _current_session_id() -> str:
|
|
394
|
+
"""Stable per-process id so dedup-per-session works.
|
|
395
|
+
|
|
396
|
+
Defaults to ``CANOPY_SESSION_ID`` when set (autopilot / external
|
|
397
|
+
runners can pass a stable id across tool calls). Falls back to the
|
|
398
|
+
UTC date so manual CLI / test runs still cluster sensibly.
|
|
399
|
+
"""
|
|
400
|
+
explicit = os.environ.get("CANOPY_SESSION_ID")
|
|
401
|
+
if explicit:
|
|
402
|
+
return explicit
|
|
403
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _decision_already_logged(
|
|
407
|
+
workspace_root: Path, feature: str, title: str, session: str,
|
|
408
|
+
) -> bool:
|
|
409
|
+
for e in reversed(_load_entries(workspace_root, feature)):
|
|
410
|
+
if e.get("session") != session:
|
|
411
|
+
return False
|
|
412
|
+
if e.get("kind") == "decision" and e.get("title") == title:
|
|
413
|
+
return True
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _comment_read_already_logged(
|
|
418
|
+
workspace_root: Path, feature: str, comment_id: str, session: str,
|
|
419
|
+
) -> bool:
|
|
420
|
+
for e in reversed(_load_entries(workspace_root, feature)):
|
|
421
|
+
if e.get("session") != session:
|
|
422
|
+
return False
|
|
423
|
+
if e.get("kind") == "comment_read" and e.get("comment_id") == comment_id:
|
|
424
|
+
return True
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _classifier_already_logged(
|
|
429
|
+
workspace_root: Path, feature: str, session: str,
|
|
430
|
+
) -> bool:
|
|
431
|
+
for e in reversed(_load_entries(workspace_root, feature)):
|
|
432
|
+
if e.get("session") != session:
|
|
433
|
+
return False
|
|
434
|
+
if e.get("kind") == "classifier_resolved":
|
|
435
|
+
return True
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _refresh_render(workspace_root: Path, feature: str) -> None:
|
|
440
|
+
entries = _load_entries(workspace_root, feature)
|
|
441
|
+
text = _render(feature, entries) if entries else ""
|
|
442
|
+
_atomic_write(render_path(workspace_root, feature), text)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ── Markdown rendering ──────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _render(feature: str, entries: list[dict[str, Any]]) -> str:
|
|
449
|
+
resolutions = _render_resolutions(entries)
|
|
450
|
+
pr_context = _render_pr_context(entries)
|
|
451
|
+
sessions = _render_sessions(entries)
|
|
452
|
+
parts = [f"# Feature: {feature}\n"]
|
|
453
|
+
parts.append("## Resolutions log\n\n" + (resolutions or "_(no comment activity yet)_\n"))
|
|
454
|
+
parts.append("## PR context\n\n" + (pr_context or "_(no PRs opened yet)_\n"))
|
|
455
|
+
parts.append("## Sessions (newest first)\n\n" + (sessions or "_(no sessions logged yet)_\n"))
|
|
456
|
+
return "\n".join(parts)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _render_resolutions(entries: list[dict[str, Any]]) -> str:
|
|
460
|
+
"""Per-comment outcomes — never compacted."""
|
|
461
|
+
items: list[str] = []
|
|
462
|
+
for e in entries:
|
|
463
|
+
kind = e.get("kind")
|
|
464
|
+
if kind == "comment_resolved":
|
|
465
|
+
sha = (e.get("commit_sha") or "")[:8]
|
|
466
|
+
cid = e.get("comment_id", "?")
|
|
467
|
+
author = e.get("author", "?")
|
|
468
|
+
file_loc = _file_loc(e)
|
|
469
|
+
gist = e.get("gist", "")
|
|
470
|
+
items.append(_resolution_line("✓", cid, author, file_loc, f"resolved by {sha}", gist))
|
|
471
|
+
elif kind == "classifier_resolved":
|
|
472
|
+
for t in e.get("threads", []):
|
|
473
|
+
cid = t.get("id", t.get("comment_id", "?"))
|
|
474
|
+
author = t.get("author", "?")
|
|
475
|
+
file_loc = _thread_file_loc(t)
|
|
476
|
+
reason = t.get("reason", "file modified since")
|
|
477
|
+
items.append(_resolution_line("⊙", cid, author, file_loc, "likely-resolved by classifier", reason))
|
|
478
|
+
elif kind == "comment_deferred":
|
|
479
|
+
cid = e.get("comment_id", "?")
|
|
480
|
+
author = e.get("author", "?")
|
|
481
|
+
file_loc = _file_loc(e)
|
|
482
|
+
items.append(_resolution_line("⊘", cid, author, file_loc, "DEFERRED", e.get("reason", "")))
|
|
483
|
+
if not items:
|
|
484
|
+
return ""
|
|
485
|
+
# Newest first.
|
|
486
|
+
return "\n".join(reversed(items)) + "\n"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _render_pr_context(entries: list[dict[str, Any]]) -> str:
|
|
490
|
+
"""One block per PR + ordered updates."""
|
|
491
|
+
by_pr: dict[tuple[str, int], dict[str, Any]] = {}
|
|
492
|
+
for e in entries:
|
|
493
|
+
if e.get("kind") == "pr_context":
|
|
494
|
+
key = (e.get("repo", ""), e.get("pr_number", 0))
|
|
495
|
+
by_pr.setdefault(key, {"context": None, "updates": []})
|
|
496
|
+
by_pr[key]["context"] = e
|
|
497
|
+
elif e.get("kind") == "pr_update":
|
|
498
|
+
key = (e.get("repo", ""), e.get("pr_number", 0))
|
|
499
|
+
by_pr.setdefault(key, {"context": None, "updates": []})
|
|
500
|
+
by_pr[key]["updates"].append(e)
|
|
501
|
+
if not by_pr:
|
|
502
|
+
return ""
|
|
503
|
+
|
|
504
|
+
blocks: list[str] = []
|
|
505
|
+
for (repo, pr_num), data in sorted(by_pr.items(), key=lambda kv: -kv[0][1]):
|
|
506
|
+
ctx = data["context"] or {}
|
|
507
|
+
title = ctx.get("title", "(no title recorded)")
|
|
508
|
+
opened = ctx.get("at", "")[:10]
|
|
509
|
+
base = ctx.get("base", "main")
|
|
510
|
+
url = ctx.get("url", "")
|
|
511
|
+
rationale = ctx.get("rationale", "")
|
|
512
|
+
header = f"### PR #{pr_num} — {repo} — {title}\n"
|
|
513
|
+
body_lines = [f"**Opened:** {opened} against `{base}`"]
|
|
514
|
+
if url:
|
|
515
|
+
body_lines.append(f"**URL:** {url}")
|
|
516
|
+
if rationale:
|
|
517
|
+
body_lines.append(f"**Rationale:** {rationale}")
|
|
518
|
+
if data["updates"]:
|
|
519
|
+
body_lines.append("")
|
|
520
|
+
body_lines.append("**Updates:**")
|
|
521
|
+
# Newest update first.
|
|
522
|
+
for u in reversed(data["updates"]):
|
|
523
|
+
body_lines.append(f"- {u.get('at', '')[:10]}: {u.get('summary', '')}")
|
|
524
|
+
blocks.append(header + "\n".join(body_lines) + "\n")
|
|
525
|
+
return "\n".join(blocks)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _render_sessions(entries: list[dict[str, Any]]) -> str:
|
|
529
|
+
"""Group by session id, newest session first, with a per-entry digest."""
|
|
530
|
+
sessions: dict[str, list[dict[str, Any]]] = {}
|
|
531
|
+
order: list[str] = []
|
|
532
|
+
for e in entries:
|
|
533
|
+
sid = e.get("session") or "_unsessioned"
|
|
534
|
+
if sid not in sessions:
|
|
535
|
+
sessions[sid] = []
|
|
536
|
+
order.append(sid)
|
|
537
|
+
sessions[sid].append(e)
|
|
538
|
+
if not sessions:
|
|
539
|
+
return ""
|
|
540
|
+
|
|
541
|
+
blocks: list[str] = []
|
|
542
|
+
for sid in reversed(order):
|
|
543
|
+
block = [f"### {sid}"]
|
|
544
|
+
for e in sessions[sid]:
|
|
545
|
+
block.append(_session_line(e))
|
|
546
|
+
blocks.append("\n".join(block) + "\n")
|
|
547
|
+
return "\n".join(blocks)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ── Tiny render helpers ─────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _resolution_line(
|
|
554
|
+
glyph: str, cid: Any, author: str, file_loc: str, status: str, gist: str,
|
|
555
|
+
) -> str:
|
|
556
|
+
head = f"- {glyph} comment {cid} ({author}{file_loc}) {status}"
|
|
557
|
+
if gist:
|
|
558
|
+
return head + f"\n {gist}"
|
|
559
|
+
return head
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _file_loc(entry: dict[str, Any]) -> str:
|
|
563
|
+
path = entry.get("path", "")
|
|
564
|
+
line = entry.get("line", 0)
|
|
565
|
+
if not path:
|
|
566
|
+
return ""
|
|
567
|
+
if line:
|
|
568
|
+
return f", {path}:{line}"
|
|
569
|
+
return f", {path}"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _thread_file_loc(thread: dict[str, Any]) -> str:
|
|
573
|
+
return _file_loc(thread)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _session_line(entry: dict[str, Any]) -> str:
|
|
577
|
+
kind = entry.get("kind", "")
|
|
578
|
+
when = entry.get("at", "")[11:19] # HH:MM:SS slice of ISO
|
|
579
|
+
if kind == "decision":
|
|
580
|
+
title = entry.get("title", "")
|
|
581
|
+
rationale = entry.get("rationale", "")
|
|
582
|
+
if rationale:
|
|
583
|
+
return f"- [{when}] **decision:** {title} — {rationale}"
|
|
584
|
+
return f"- [{when}] **decision:** {title}"
|
|
585
|
+
if kind == "pause":
|
|
586
|
+
return f"- [{when}] **pause:** {entry.get('reason', '')}"
|
|
587
|
+
if kind == "comment_read":
|
|
588
|
+
cid = entry.get("comment_id", "?")
|
|
589
|
+
author = entry.get("author", "?")
|
|
590
|
+
path = entry.get("path", "")
|
|
591
|
+
line = entry.get("line", 0)
|
|
592
|
+
loc = f" {path}:{line}" if path else ""
|
|
593
|
+
excerpt = entry.get("body_excerpt", "")
|
|
594
|
+
suffix = f" — {excerpt}" if excerpt else ""
|
|
595
|
+
return f"- [{when}] read comment {cid} ({author}{loc}){suffix}"
|
|
596
|
+
if kind == "comment_resolved":
|
|
597
|
+
cid = entry.get("comment_id", "?")
|
|
598
|
+
sha = (entry.get("commit_sha") or "")[:8]
|
|
599
|
+
return f"- [{when}] resolved comment {cid} → {sha}"
|
|
600
|
+
if kind == "comment_deferred":
|
|
601
|
+
cid = entry.get("comment_id", "?")
|
|
602
|
+
return f"- [{when}] deferred comment {cid}: {entry.get('reason', '')}"
|
|
603
|
+
if kind == "classifier_resolved":
|
|
604
|
+
n = len(entry.get("threads", []))
|
|
605
|
+
return f"- [{when}] classifier marked {n} thread(s) likely-resolved"
|
|
606
|
+
if kind == "pr_context":
|
|
607
|
+
return f"- [{when}] opened PR #{entry.get('pr_number', '?')} ({entry.get('repo', '')})"
|
|
608
|
+
if kind == "pr_update":
|
|
609
|
+
return f"- [{when}] PR #{entry.get('pr_number', '?')}: {entry.get('summary', '')}"
|
|
610
|
+
if kind == "event":
|
|
611
|
+
return f"- [{when}] {entry.get('summary', '')}"
|
|
612
|
+
return f"- [{when}] {kind}: {entry.get('summary', entry.get('title', ''))}"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``.code-workspace`` renderer for canopy worktrees (M6).
|
|
2
|
+
|
|
3
|
+
Pure function: given a feature name + the per-repo worktree paths +
|
|
4
|
+
optional ``ide_settings`` overrides per repo, return the JSON content
|
|
5
|
+
of a VS Code multi-root workspace file.
|
|
6
|
+
|
|
7
|
+
The atomic writer is in ``actions/bootstrap.py`` — keeping the renderer
|
|
8
|
+
side-effect-free makes it trivially unit-testable.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..workspace.workspace import Workspace
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_code_workspace(
|
|
19
|
+
workspace: Workspace,
|
|
20
|
+
feature_name: str,
|
|
21
|
+
worktree_paths: dict[str, Path],
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Return the JSON body for ``<feature>.code-workspace``.
|
|
24
|
+
|
|
25
|
+
``worktree_paths`` maps canopy repo names to absolute worktree
|
|
26
|
+
directories. Per-repo ``ide_settings`` from canopy.toml are merged
|
|
27
|
+
into the folder's ``settings`` block — useful for things like
|
|
28
|
+
``python.defaultInterpreterPath = "${workspaceFolder}/.venv/bin/python"``.
|
|
29
|
+
"""
|
|
30
|
+
folders = []
|
|
31
|
+
for repo_name in sorted(worktree_paths.keys()):
|
|
32
|
+
path = worktree_paths[repo_name]
|
|
33
|
+
try:
|
|
34
|
+
state = workspace.get_repo(repo_name)
|
|
35
|
+
except KeyError:
|
|
36
|
+
state = None
|
|
37
|
+
ide_settings = state.config.ide_settings if state else {}
|
|
38
|
+
folder: dict = {
|
|
39
|
+
"name": f"{repo_name} ({feature_name})",
|
|
40
|
+
"path": str(path),
|
|
41
|
+
}
|
|
42
|
+
if ide_settings:
|
|
43
|
+
folder["settings"] = dict(ide_settings)
|
|
44
|
+
folders.append(folder)
|
|
45
|
+
|
|
46
|
+
return json.dumps(
|
|
47
|
+
{"folders": folders, "settings": {"canopy.feature": feature_name}},
|
|
48
|
+
indent=2,
|
|
49
|
+
)
|