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.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. 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
+ )