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,582 @@
1
+ """feature_resume — switch-aware compound action.
2
+
3
+ Single command takes an alias and gets the user back in business:
4
+ 1. Resolve alias to feature name.
5
+ 2. If feature isn't canonical, switch to it (which will bump last_visit once T13 lands).
6
+ 3. Compute "since prior anchor" sections (commits, threads, drafts...).
7
+ 4. Compute current_state (feature_state, CI, bot rollup, branch position).
8
+ 5. Build intent_hints from the deltas + current state.
9
+ 6. If no switch happened, bump last_visit at the end.
10
+ 7. Return the complete brief.
11
+
12
+ Refreshes from GitHub/Linear on every call. No caching at this layer.
13
+
14
+ Single-bump invariant: last_visit moves exactly once per feature_resume call.
15
+ - switch ran → switch bumps (T13). Resume does NOT bump again.
16
+ - no switch → resume bumps at the end, after delta is computed.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime, timezone
21
+ from typing import Any
22
+
23
+ from ..workspace.workspace import Workspace
24
+ from . import last_visit as lv
25
+ from . import slots as slots_mod
26
+ from .aliases import resolve_feature
27
+ from .switch import switch
28
+
29
+
30
+ _UNSET = object() # sentinel to distinguish "not passed" from "explicitly None"
31
+
32
+
33
+ def resume_summary(
34
+ workspace: Workspace, feature: str, prior_iso: str | None = _UNSET, # type: ignore[assignment]
35
+ ) -> dict[str, Any]:
36
+ """Counts-only view embedded in switch return.
37
+
38
+ No GH fetch on failure — falls back to local state and sets degraded: True.
39
+
40
+ ``prior_iso`` should be the last_visit anchor captured BEFORE any
41
+ mark_visited call so the diff window is meaningful.
42
+
43
+ - Pass the prior anchor explicitly (including ``None`` for first visit) when
44
+ you've already captured it (e.g. switch.py).
45
+ - Omit it (leave as default) to let resume_summary read the current
46
+ last_visit itself. Only do this when mark_visited has NOT yet run.
47
+ """
48
+ from . import historian
49
+
50
+ # Resolve anchor: explicit wins; fallback reads live state.
51
+ if prior_iso is _UNSET:
52
+ visit = lv.get_last_visit(workspace, feature)
53
+ anchor: str | None = visit["last_visit"] if visit else None
54
+ else:
55
+ anchor = prior_iso # type: ignore[assignment]
56
+
57
+ memory_present = bool(
58
+ historian.format_for_agent(workspace.config.root, feature)
59
+ )
60
+
61
+ if anchor is None:
62
+ return {
63
+ "last_visit": None,
64
+ "first_visit": True,
65
+ "new_commit_count": 0,
66
+ "new_thread_count": 0,
67
+ "github_resolved_count": 0,
68
+ "ci_changed": False,
69
+ "draft_replies_pending": 0,
70
+ "memory_present": memory_present,
71
+ "degraded": False,
72
+ }
73
+
74
+ new_commit_count = sum(
75
+ len(c) for c in _commits_since(workspace, feature, anchor).values()
76
+ )
77
+ degraded = False
78
+ new_thread_count = 0
79
+ github_resolved_count = 0
80
+ try:
81
+ td = _threads_delta(workspace, feature, anchor)
82
+ new_thread_count = len(td["new"])
83
+ github_resolved_count = len(td["resolved_gh"])
84
+ except Exception:
85
+ degraded = True
86
+
87
+ return {
88
+ "last_visit": anchor,
89
+ "first_visit": False,
90
+ "new_commit_count": new_commit_count,
91
+ "new_thread_count": new_thread_count,
92
+ "github_resolved_count": github_resolved_count,
93
+ "ci_changed": False, # v1: no persisted snapshot to diff
94
+ "draft_replies_pending": 0, # v1: skip for switch tail
95
+ "memory_present": memory_present,
96
+ "degraded": degraded,
97
+ }
98
+
99
+
100
+ def feature_resume(workspace: Workspace, alias: str) -> dict[str, Any]:
101
+ """Resolve alias, switch-if-needed, build and return the resume brief."""
102
+ feature = resolve_feature(workspace, alias)
103
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
104
+
105
+ # 1. Capture prior anchor BEFORE any switch can move it.
106
+ prior_visit = lv.get_last_visit(workspace, feature)
107
+ prior_iso: str | None = prior_visit["last_visit"] if prior_visit else None
108
+
109
+ # 2. Switch-if-needed. Read slot state to decide.
110
+ switch_summary: dict | None = None
111
+ state = slots_mod.read_state(workspace)
112
+ is_canonical = (
113
+ state is not None
114
+ and state.canonical is not None
115
+ and state.canonical.feature == feature
116
+ )
117
+ if not is_canonical:
118
+ switch_summary = switch(workspace, feature)
119
+ # T13 will make switch bump last_visit internally. Until then, the
120
+ # single-bump invariant is: resume does NOT bump when switch ran.
121
+
122
+ # 3. Empty containers — T7–T12 expand _populate_since / _populate_current.
123
+ since: dict[str, Any] = {
124
+ "commits": {},
125
+ "threads_new": [],
126
+ "threads_resolved_on_github": [],
127
+ "threads_resolved_by_canopy": [],
128
+ "ci_status_delta": {},
129
+ "draft_replies_pending": 0,
130
+ "historian_excerpt": "",
131
+ }
132
+ current: dict[str, Any] = {
133
+ "feature_state": None,
134
+ "open_thread_count": 0,
135
+ "ci_summary_per_repo": {},
136
+ "bot_unresolved_total": 0,
137
+ "draft_replies_summary": {"addressed_total": 0, "unaddressed_total": 0},
138
+ "branch_position_per_repo": {},
139
+ "linear_issue": None,
140
+ "linear_url": None,
141
+ }
142
+
143
+ # 4. Populate sections (prior_iso may be None on first visit).
144
+ if prior_iso is not None:
145
+ since = _populate_since(workspace, feature, prior_iso, since)
146
+ current = _populate_current(workspace, feature, current)
147
+
148
+ # 5. Build intent hints from populated shapes.
149
+ intent_hints = _intent_hints(since, current, prior_iso is None)
150
+
151
+ # 6. Single-bump: only bump when switch didn't already run.
152
+ if switch_summary is None:
153
+ lv.mark_visited(workspace, feature)
154
+
155
+ # 7. Window duration (None on first visit).
156
+ window_hours = _hours_between(prior_iso, now_iso) if prior_iso is not None else None
157
+
158
+ # Strip transport-only internal key before returning.
159
+ current.pop("__feature_name__", None)
160
+
161
+ return {
162
+ "version": 1,
163
+ "feature": feature,
164
+ "now": now_iso,
165
+ "last_visit": prior_iso, # the PRIOR anchor, not freshly bumped
166
+ "first_visit": prior_iso is None,
167
+ "window_hours": window_hours,
168
+ "switch_performed": switch_summary is not None,
169
+ "switch_summary": switch_summary,
170
+ "intent_hints": intent_hints,
171
+ "since_last_visit": since,
172
+ "current_state": current,
173
+ }
174
+
175
+
176
+ # ── Section populators (stubs) ────────────────────────────────────────────────
177
+ # T7–T12 fill these in. T6 leaves the shapes as-is.
178
+
179
+ def _populate_since(
180
+ workspace: Workspace,
181
+ feature: str,
182
+ last_visit_iso: str,
183
+ since: dict[str, Any],
184
+ ) -> dict[str, Any]:
185
+ """T7-T12 fill this. T7 populates commits. T8 populates thread deltas."""
186
+ since["commits"] = _commits_since(workspace, feature, last_visit_iso)
187
+ threads = _threads_delta(workspace, feature, last_visit_iso)
188
+ since["threads_new"] = threads["new"]
189
+ since["threads_resolved_on_github"] = threads["resolved_gh"]
190
+ since["threads_resolved_by_canopy"] = _resolutions_by_canopy_since(
191
+ workspace, feature, last_visit_iso,
192
+ )
193
+
194
+ # T11: draft_replies_pending — count of addressed-but-not-yet-posted drafts.
195
+ # Only populate when there's a prior anchor (not first visit).
196
+ from . import draft_replies as dr
197
+ try:
198
+ drafts = dr.draft_replies(workspace, feature)
199
+ since["draft_replies_pending"] = sum(
200
+ len(r.get("addressed") or [])
201
+ for r in (drafts.get("repos") or {}).values()
202
+ )
203
+ except Exception:
204
+ pass # leaves the default 0
205
+
206
+ # T12: historian_excerpt — sessions/events/decisions since last_visit.
207
+ from . import historian
208
+ try:
209
+ since["historian_excerpt"] = historian.format_for_agent_since(
210
+ workspace.config.root, feature, last_visit_iso,
211
+ )
212
+ except Exception:
213
+ pass # leaves the default ""
214
+
215
+ return since
216
+
217
+
218
+ def _commits_since(workspace: Workspace, feature: str, since_iso: str) -> dict[str, list]:
219
+ """Populate per-repo commits authored after since_iso on the feature branch.
220
+
221
+ Returns {repo_name: [commit dicts]} where each commit has
222
+ {sha, short_sha, at, author, subject}. Per-repo errors (missing branch,
223
+ git failures) silently default to empty list; exceptions don't crash the brief.
224
+ """
225
+ from ..git import repo as git
226
+ from .aliases import repos_for_feature
227
+
228
+ out: dict[str, list] = {}
229
+ repos_map = repos_for_feature(workspace, feature)
230
+
231
+ for repo_name, branch in repos_map.items():
232
+ try:
233
+ state = workspace.get_repo(repo_name)
234
+ out[repo_name] = git.log_since(state.abs_path, branch, since_iso)
235
+ except Exception:
236
+ # Missing repo in workspace, or git error — default to empty list.
237
+ out[repo_name] = []
238
+
239
+ return out
240
+
241
+
242
+ def _pr_coords_per_repo(
243
+ workspace: Workspace, feature: str,
244
+ ) -> dict[str, dict | None]:
245
+ """Return {repo_name: {"owner": str, "repo_slug": str, "pr_number": int} | None}.
246
+
247
+ Uses the same pattern as FeatureCoordinator.review_status: iterates repos
248
+ in the feature lane, resolves remote URL → owner/slug, finds the open PR.
249
+ On any per-repo error (no remote, unparseable URL, no PR) returns None for
250
+ that repo. Propagates only hard exceptions (feature not found, etc.).
251
+ """
252
+ from ..git import repo as git
253
+ from ..integrations.github import _extract_owner_repo, find_pull_request
254
+ from .aliases import repos_for_feature
255
+
256
+ repos_map = repos_for_feature(workspace, feature)
257
+ out: dict[str, dict | None] = {}
258
+
259
+ for repo_name, branch in repos_map.items():
260
+ try:
261
+ state = workspace.get_repo(repo_name)
262
+ remote = git.remote_url(state.abs_path)
263
+ if not remote:
264
+ out[repo_name] = None
265
+ continue
266
+ parsed = _extract_owner_repo(remote)
267
+ if not parsed:
268
+ out[repo_name] = None
269
+ continue
270
+ owner, repo_slug = parsed
271
+ pr = find_pull_request(workspace.config.root, owner, repo_slug, branch)
272
+ if pr is None:
273
+ out[repo_name] = None
274
+ else:
275
+ out[repo_name] = {
276
+ "owner": owner,
277
+ "repo_slug": repo_slug,
278
+ "pr_number": pr["number"],
279
+ }
280
+ except Exception:
281
+ out[repo_name] = None
282
+
283
+ return out
284
+
285
+
286
+ def _threads_delta(
287
+ workspace: Workspace, feature: str, since_iso: str,
288
+ ) -> dict[str, list]:
289
+ """Return {"new": [...], "resolved_gh": [...]}.
290
+
291
+ Calls list_review_threads per-repo+PR. On ANY exception (no PR yet,
292
+ GH unreachable, etc.), returns {"new": [], "resolved_gh": []} and
293
+ swallows. Never crashes the brief.
294
+ """
295
+ from ..integrations import github as gh
296
+ from . import thread_resolutions as tr
297
+
298
+ try:
299
+ pr_coords = _pr_coords_per_repo(workspace, feature)
300
+ except Exception:
301
+ return {"new": [], "resolved_gh": []}
302
+
303
+ canopy_log = tr.load(workspace.config.root)
304
+ new_threads: list[dict] = []
305
+ resolved_gh: list[dict] = []
306
+
307
+ for repo_name, coords in pr_coords.items():
308
+ if not coords:
309
+ continue
310
+ owner = coords["owner"]
311
+ repo_slug = coords["repo_slug"]
312
+ pr_number = coords["pr_number"]
313
+ try:
314
+ threads = gh.list_review_threads(
315
+ workspace.config.root, owner, repo_slug, pr_number,
316
+ )
317
+ except Exception:
318
+ continue
319
+ for t in threads:
320
+ first = (t.get("comments") or [None])[0]
321
+ created_at = (first or {}).get("created_at", "")
322
+ if (not t["is_resolved"]) and created_at > since_iso:
323
+ new_threads.append({
324
+ "thread_id": t["thread_id"],
325
+ "comment_id": (first or {}).get("comment_id"),
326
+ "author": (first or {}).get("author", ""),
327
+ "path": (first or {}).get("path", ""),
328
+ "line": (first or {}).get("line", 0),
329
+ "body_excerpt": ((first or {}).get("body") or "")[:200],
330
+ "created_at": created_at,
331
+ "url": (first or {}).get("url", ""),
332
+ "repo": repo_name,
333
+ "pr_number": pr_number,
334
+ })
335
+ elif t["is_resolved"] and (t.get("resolved_at") or "") > since_iso:
336
+ resolved_gh.append({
337
+ "thread_id": t["thread_id"],
338
+ "resolved_at": t["resolved_at"],
339
+ "by_canopy": t["thread_id"] in canopy_log,
340
+ "repo": repo_name,
341
+ "pr_number": pr_number,
342
+ "summary_excerpt": ((first or {}).get("body") or "")[:200],
343
+ })
344
+
345
+ return {"new": new_threads, "resolved_gh": resolved_gh}
346
+
347
+
348
+ def _resolutions_by_canopy_since(
349
+ workspace: Workspace, feature: str, since_iso: str,
350
+ ) -> list[dict]:
351
+ """Bot_resolutions entries for this feature with addressed_at > since_iso."""
352
+ from . import bot_resolutions as br
353
+
354
+ out: list[dict] = []
355
+ try:
356
+ entries = br.resolutions_for_feature(workspace.config.root, feature)
357
+ except Exception:
358
+ return []
359
+ for cid, e in entries.items():
360
+ if e.get("addressed_at", "") > since_iso:
361
+ out.append({"comment_id": cid, **e})
362
+ return out
363
+
364
+
365
+ def _populate_current(
366
+ workspace: Workspace,
367
+ feature: str,
368
+ current: dict[str, Any],
369
+ ) -> dict[str, Any]:
370
+ """T9: feature_state, ci_summary_per_repo, branch_position_per_repo.
371
+
372
+ T10-T11 fill the remaining sections. Errors in any sub-section are
373
+ swallowed so the brief always returns with reasonable defaults.
374
+ """
375
+ current["__feature_name__"] = feature
376
+
377
+ from . import feature_state as fs
378
+ from . import bot_status as bs
379
+ from ..git import repo as git
380
+ from .aliases import repos_for_feature
381
+
382
+ # feature_state + ci_summary_per_repo ─────────────────────────────────
383
+ try:
384
+ st = fs.feature_state(workspace, feature)
385
+ except Exception:
386
+ st = {}
387
+
388
+ current["feature_state"] = st.get("state", "unknown")
389
+
390
+ # CI lives in summary["ci_per_repo"] → {repo: {"status": ...}}
391
+ ci_per_repo = (st.get("summary") or {}).get("ci_per_repo") or {}
392
+ current["ci_summary_per_repo"] = {
393
+ r: (info.get("status") or "no_checks")
394
+ for r, info in ci_per_repo.items()
395
+ }
396
+
397
+ # bot_unresolved_total ────────────────────────────────────────────────
398
+ try:
399
+ roll = bs.bot_comments_status(workspace, feature)
400
+ except Exception:
401
+ roll = {"repos": {}}
402
+ current["bot_unresolved_total"] = sum(
403
+ r.get("unresolved", 0) for r in (roll.get("repos") or {}).values()
404
+ )
405
+
406
+ # draft_replies_summary ───────────────────────────────────────────────
407
+ # T11: Populate from draft_replies; swallow errors, use defaults.
408
+ from . import draft_replies as dr
409
+ try:
410
+ drafts = dr.draft_replies(workspace, feature)
411
+ current["draft_replies_summary"] = {
412
+ "addressed_total": drafts.get("addressed_total", 0),
413
+ "unaddressed_total": drafts.get("unaddressed_total", 0),
414
+ }
415
+ except Exception:
416
+ pass # leaves the T6 default {addressed_total: 0, unaddressed_total: 0}
417
+
418
+ # branch_position_per_repo ────────────────────────────────────────────
419
+ pos: dict[str, dict] = {}
420
+ for repo_name, branch in repos_for_feature(workspace, feature).items():
421
+ try:
422
+ repo_state = workspace.get_repo(repo_name)
423
+ default = repo_state.config.default_branch
424
+ ahead, behind = git.divergence(repo_state.abs_path, branch, default)
425
+ last_sync_at = git.commit_iso_date(
426
+ repo_state.abs_path, f"{branch}...{default}",
427
+ )
428
+ pos[repo_name] = {
429
+ "branch": branch,
430
+ "default_branch": default,
431
+ "ahead": ahead,
432
+ "behind": behind,
433
+ "last_sync_at": last_sync_at or "",
434
+ }
435
+ except Exception:
436
+ continue
437
+ current["branch_position_per_repo"] = pos
438
+
439
+ # linear_issue / linear_url — lifted from FeatureLane ─────────────────
440
+ try:
441
+ from ..features.coordinator import FeatureCoordinator
442
+ coord = FeatureCoordinator(workspace)
443
+ lane = coord.status(feature)
444
+ current["linear_issue"] = getattr(lane, "linear_issue", None) or None
445
+ current["linear_url"] = getattr(lane, "linear_url", None) or None
446
+ except Exception:
447
+ pass # leaves the defaults (None) from initialization
448
+
449
+ # open_thread_count — rolled up from list_review_threads per PR ───────
450
+ current["open_thread_count"] = _open_thread_count(workspace, feature)
451
+
452
+ return current
453
+
454
+
455
+ # ── Helpers for current_state ─────────────────────────────────────────────────
456
+
457
+ def _open_thread_count(workspace: Workspace, feature: str) -> int:
458
+ """Total unresolved review threads across all repos+PRs for the feature.
459
+
460
+ Calls list_review_threads per-repo+PR using the same coords that
461
+ _threads_delta uses. On any exception, returns 0 and swallows.
462
+
463
+ # TODO: cache list_review_threads per resume call to avoid 2x round-trips
464
+ # when _threads_delta already ran in _populate_since (milestone-3 item).
465
+ """
466
+ from ..integrations import github as gh
467
+
468
+ try:
469
+ pr_coords = _pr_coords_per_repo(workspace, feature)
470
+ except Exception:
471
+ return 0
472
+
473
+ total = 0
474
+ for repo_name, coords in pr_coords.items():
475
+ if not coords:
476
+ continue
477
+ try:
478
+ threads = gh.list_review_threads(
479
+ workspace.config.root,
480
+ coords["owner"],
481
+ coords["repo_slug"],
482
+ coords["pr_number"],
483
+ )
484
+ except Exception:
485
+ continue
486
+ total += sum(1 for t in threads if not t.get("is_resolved", False))
487
+ return total
488
+
489
+
490
+ # ── Intent hints ──────────────────────────────────────────────────────────────
491
+
492
+ def _intent_hints(
493
+ since: dict[str, Any],
494
+ current: dict[str, Any],
495
+ first_visit: bool,
496
+ ) -> list[dict]:
497
+ """Build prioritized next-action suggestions from the brief data.
498
+
499
+ Hints are derived, not stored — recomputed on every call. Ordering by
500
+ ``priority`` field; the agent typically reads top 3.
501
+ """
502
+ hints: list[dict] = []
503
+
504
+ # Address new comments (highest priority — reviewer activity is most
505
+ # actionable thing after returning).
506
+ new_threads = since.get("threads_new") or []
507
+ if new_threads:
508
+ hints.append({
509
+ "kind": "address_comments",
510
+ "summary": f"{len(new_threads)} new PR comment(s) since last visit",
511
+ "suggested_tool": "review_comments",
512
+ "suggested_args": {"alias": current.get("__feature_name__")},
513
+ "priority": 1,
514
+ })
515
+
516
+ # Align with default branch (the user's "align with dev" intent path).
517
+ behind_per_repo = {
518
+ r: info.get("behind", 0)
519
+ for r, info in (current.get("branch_position_per_repo") or {}).items()
520
+ if info.get("behind", 0) > 0
521
+ }
522
+ if behind_per_repo:
523
+ worst = max(behind_per_repo.items(), key=lambda kv: kv[1])
524
+ hints.append({
525
+ "kind": "align_with_default",
526
+ "summary": (
527
+ f"behind default by {worst[1]} commits in {worst[0]}"
528
+ + (
529
+ f" (+ {len(behind_per_repo) - 1} other repos)"
530
+ if len(behind_per_repo) > 1
531
+ else ""
532
+ )
533
+ ),
534
+ "suggested_tool": "log",
535
+ "suggested_args": {"repo": worst[0]},
536
+ "priority": 2,
537
+ })
538
+
539
+ # Post drafted replies.
540
+ drafts = current.get("draft_replies_summary") or {}
541
+ if drafts.get("addressed_total", 0) > 0:
542
+ hints.append({
543
+ "kind": "post_drafts",
544
+ "summary": f"{drafts['addressed_total']} draft replies ready",
545
+ "suggested_tool": "draft_replies",
546
+ "suggested_args": {"alias": current.get("__feature_name__")},
547
+ "priority": 3,
548
+ })
549
+
550
+ # CI failing.
551
+ ci = current.get("ci_summary_per_repo") or {}
552
+ failing = [r for r, status in ci.items() if status == "failing"]
553
+ if failing:
554
+ hints.append({
555
+ "kind": "investigate_ci",
556
+ "summary": f"CI failing in {', '.join(failing)}",
557
+ "suggested_tool": "pr_checks",
558
+ "suggested_args": {"alias": current.get("__feature_name__")},
559
+ "priority": 1, # ties with comments — both are blockers
560
+ })
561
+
562
+ # First-visit special case: hint to read the linear issue.
563
+ if first_visit and current.get("linear_issue"):
564
+ hints.append({
565
+ "kind": "read_issue",
566
+ "summary": f"first visit — read {current['linear_issue']}",
567
+ "suggested_tool": "linear_get_issue",
568
+ "suggested_args": {"alias": current.get("linear_issue")},
569
+ "priority": 1,
570
+ })
571
+
572
+ hints.sort(key=lambda h: h["priority"])
573
+ return hints
574
+
575
+
576
+ # ── Helpers ───────────────────────────────────────────────────────────────────
577
+
578
+ def _hours_between(start_iso: str, end_iso: str) -> float:
579
+ """Return elapsed hours between two ISO-Z timestamps."""
580
+ def _parse(s: str) -> datetime:
581
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
582
+ return (_parse(end_iso) - _parse(start_iso)).total_seconds() / 3600.0