@agentproto/apps 0.12.0 → 0.13.0

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 (65) hide show
  1. package/dist/agents-overview/panel.mjs +14 -67
  2. package/dist/agents-overview/panel.mjs.map +1 -1
  3. package/dist/agents-overview.mjs +13 -68
  4. package/dist/agents-overview.mjs.map +1 -1
  5. package/dist/bin/sync.d.ts.map +1 -1
  6. package/dist/bin/sync.mjs +2374 -2
  7. package/dist/bin/sync.mjs.map +1 -1
  8. package/dist/bureau-sessions/panel.mjs +14 -67
  9. package/dist/bureau-sessions/panel.mjs.map +1 -1
  10. package/dist/bureau-sessions.mjs +13 -68
  11. package/dist/bureau-sessions.mjs.map +1 -1
  12. package/dist/config/fragment.d.ts +50 -0
  13. package/dist/config/fragment.d.ts.map +1 -0
  14. package/dist/config/index.d.ts +23 -0
  15. package/dist/config/index.d.ts.map +1 -0
  16. package/dist/config/ui.d.ts +43 -0
  17. package/dist/config/ui.d.ts.map +1 -0
  18. package/dist/config.mjs +2373 -0
  19. package/dist/config.mjs.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.mjs +2706 -138
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/live-session/logic.d.ts +2 -0
  25. package/dist/live-session/logic.d.ts.map +1 -1
  26. package/dist/live-session/panel.d.ts.map +1 -1
  27. package/dist/live-session/panel.mjs +27 -74
  28. package/dist/live-session/panel.mjs.map +1 -1
  29. package/dist/live-session.mjs +26 -75
  30. package/dist/live-session.mjs.map +1 -1
  31. package/dist/panel-bridge.d.ts +11 -6
  32. package/dist/panel-bridge.d.ts.map +1 -1
  33. package/dist/session-chat/index.d.ts.map +1 -1
  34. package/dist/session-chat/panel.mjs +14 -67
  35. package/dist/session-chat/panel.mjs.map +1 -1
  36. package/dist/session-chat.mjs +28 -69
  37. package/dist/session-chat.mjs.map +1 -1
  38. package/dist/session-story/panel.mjs +14 -67
  39. package/dist/session-story/panel.mjs.map +1 -1
  40. package/dist/session-story.mjs +13 -68
  41. package/dist/session-story.mjs.map +1 -1
  42. package/dist/session-viewer/ui.d.ts.map +1 -1
  43. package/dist/session-viewer.mjs +10 -0
  44. package/dist/session-viewer.mjs.map +1 -1
  45. package/dist/sessions-panel/panel.d.ts.map +1 -1
  46. package/dist/sessions-panel/panel.mjs +15 -67
  47. package/dist/sessions-panel/panel.mjs.map +1 -1
  48. package/dist/sessions-panel.mjs +14 -68
  49. package/dist/sessions-panel.mjs.map +1 -1
  50. package/dist/work-board/panel.d.ts +1 -1
  51. package/dist/work-board/panel.d.ts.map +1 -1
  52. package/dist/work-board/panel.generated.d.ts +1 -1
  53. package/dist/work-board/panel.generated.d.ts.map +1 -1
  54. package/dist/work-board/panel.mjs +293 -62
  55. package/dist/work-board/panel.mjs.map +1 -1
  56. package/dist/work-board.mjs +293 -62
  57. package/dist/work-board.mjs.map +1 -1
  58. package/package.json +12 -4
  59. package/repo-maintenance/.agentproto/APP.md +22 -0
  60. package/repo-maintenance/.agentproto/agents/repo-maintenance-reviewer/AGENT.md +142 -0
  61. package/repo-maintenance/.agentproto/workflows/maintain/WORKFLOW.md +262 -0
  62. package/repo-maintenance/.agentproto/workflows/maintain/entry.mjs +672 -0
  63. package/repo-maintenance/README.md +79 -0
  64. package/repo-maintenance/routines/repo-maintenance-daily/ROUTINE.md +77 -0
  65. package/repo-maintenance/routines/repo-maintenance-weekly/ROUTINE.md +82 -0
@@ -0,0 +1,672 @@
1
+ // Runtime source of truth for WORKFLOW.md's step graph (mirrors
2
+ // `@agentproto/worktree`'s routines/worktree-gc-notify/entry.mjs pattern) —
3
+ // the frontmatter mirrors this by id+kind for governance (`reconcileEntry`
4
+ // only checks the top-level id/kind sequence, never nested step bodies).
5
+ //
6
+ // This has to be entry-based for ONE reason: the `review` map step's
7
+ // per-candidate `model` selector picks haiku vs sonnet from `item
8
+ // .residualFileCount` at RUN time. `@agentproto/workflow`'s `defineWorkflow`
9
+ // (the pure-.md / TS-authored path) hard-rejects a non-string `model` on a
10
+ // `kind:"agent"` step — "a run-time selector is only available on the
11
+ // TS-authored runtime step" — and a plain WORKFLOW.md frontmatter can't hold
12
+ // a JS function at all (YAML has no function literal). The `entry:` loader
13
+ // path is the one place a real Selector function survives: `loadWorkflowHandle`
14
+ // returns this module's default export UNVALIDATED by `defineWorkflow`,
15
+ // then `compileWorkflow` passes an agent step's function-valued `prompt`
16
+ // (and, since `compileAgentStep` never type-checks `model`, its `model` too)
17
+ // straight through.
18
+
19
+ import { tmpdir } from "node:os"
20
+ import { join } from "node:path"
21
+
22
+ const DEFAULT_REVIEW_MODEL_SMALL = "claude-haiku-4-5-20251001"
23
+ const DEFAULT_REVIEW_MODEL_LARGE = "claude-sonnet-5"
24
+ /** Reviews per run — 500+ candidates in one run is hours of agent turns;
25
+ * daily runs walk the backlog instead (a reviewed tip is skipped next time). */
26
+ const DEFAULT_MAX_REVIEWS = 40
27
+ /** Longest name list the markdown report inlines; the full lists are in the
28
+ * run output (`gaps`, …). */
29
+ const REPORT_LIST_CAP = 20
30
+
31
+ const REVIEWER_REF = "@agentproto/repo-maintenance-reviewer"
32
+
33
+ /** Where review worktrees live — must match `reviewWorktreeRoot()` in
34
+ * `@agentproto/runtime` (review-worktree.ts), which refuses any other path. */
35
+ const REVIEW_WORKTREE_ROOT = join(tmpdir(), "agentproto-maintain-review")
36
+
37
+ /** Every reviewer works in its OWN disposable detached worktree of the tip
38
+ * under review (`item.reviewWorktree`), never in the live checkout. A
39
+ * reviewer once ran `git stash && git checkout <old branch>` in the user's
40
+ * main checkout — stashing their WIP and deleting the directory every later
41
+ * reviewer was spawned in. In its own worktree a checkout or reset harms
42
+ * nothing; the worktree is removed when the item ends, again right after
43
+ * the review map, and once more in `finally` (failure / cancel). */
44
+ const REVIEWER_CWD = "$item.reviewWorktree"
45
+
46
+ /** `<root>/<repoName>-<tip sha>` — deterministic per item, so every step of
47
+ * an item (and the cleanup) names the same path without sharing state. */
48
+ function reviewWorktreePath(repoName, sha) {
49
+ return join(REVIEW_WORKTREE_ROOT, `${String(repoName ?? "repo").replace(/[^A-Za-z0-9._-]/g, "_")}-${sha}`)
50
+ }
51
+
52
+ const REVIEW_WORKTREE = (id, action) => ({
53
+ id,
54
+ kind: "tool",
55
+ tool: "branch_gc_review_worktree",
56
+ inputs:
57
+ action === "add"
58
+ ? { repoRoot: "$steps.branchGcPlan.plan.repoRoot", action: "add", path: "$item.reviewWorktree", sha: "$item.sha" }
59
+ : { repoRoot: "$steps.branchGcPlan.plan.repoRoot", action: "remove", paths: ["$item.reviewWorktree"] },
60
+ })
61
+
62
+ const REVIEW_PROMPT =
63
+ "Review the {{item.kind}} branch `{{item.name}}` (tip {{item.sha}}) in the repo at " +
64
+ "{{item.reviewWorktree}}. It is unmerged relative to base " +
65
+ "{{steps.branchGcPlan.plan.base}} (base sha {{item.base}})" +
66
+ "{{#item.compareBase}}, compare base {{item.compareBase}} (pre-rewrite history — commit " +
67
+ "shas from that history do not exist on the current base; compare CONTENT, not shas){{/item.compareBase}}. " +
68
+ "\n\nCoverage already proved what's in base by content: {{item.coverage}}. The residual files " +
69
+ "NOT provably in base are: {{item.residualFiles}}. Merge base: {{item.mergeBase}}. Merged tree " +
70
+ "(the tree base would have if this branch merged cleanly, or null when the merge conflicts): " +
71
+ "{{#item.mergedTree}}{{item.mergedTree}}{{/item.mergedTree}}{{^item.mergedTree}}null{{/item.mergedTree}}. Ahead {{item.ahead}}, behind {{item.behind}}. Push state: {{item.pushed}}. " +
72
+ "Refs of this branch (across its tips): {{item.refs}}." +
73
+ "{{#item.otherTips}}\n\nThis branch name has OLDER tip(s) besides {{item.sha}} — " +
74
+ "{{item.otherTips}} (JSON array of { sha, refs }; each sha may carry work the newer tip does " +
75
+ "not). The coverage above describes ONLY the primary tip {{item.sha}}. Inspect EVERY sha " +
76
+ "(git log/show/diff/ls-tree, `git show <sha>:<path>` — they are all commits of this repo) " +
77
+ "before deciding.{{/item.otherTips}}" +
78
+ "\n\n{{item.reviewWorktree}} (your working directory) is a disposable detached worktree made " +
79
+ "for this review alone and deleted afterwards. Read the branch through its sha (git log/show/" +
80
+ "diff/ls-tree, `git show <sha>:<path>` for a file); never checkout, switch, stash, reset or " +
81
+ "modify files — the stash list and refs are shared with every other checkout of this repo." +
82
+ "\n\nRecord your verdict by calling branch_gc_verdict with repoRoot=" +
83
+ "\"{{item.reviewWorktree}}\", name=\"{{item.name}}\", the tip's sha, " +
84
+ "a triage block, and — ONLY when you agree deleting that tip's work loses nothing of value — a " +
85
+ "gate block with agree:true and non-empty evidence. Call branch_gc_verdict exactly once per " +
86
+ "tip: once for {{item.sha}}{{#item.otherTips}}, and once for every sha in {{item.otherTips}}{{/item.otherTips}}, then stop."
87
+
88
+ const NUDGE_PROMPT =
89
+ "You did not call branch_gc_verdict for every tip of `{{item.name}}` — the store is still " +
90
+ "missing verdicts for: {{item.missingShas}} (tip shas of this branch; " +
91
+ "{{item.sha}} is the primary). Call it now for EACH missing sha, with your verdict: " +
92
+ "repoRoot=\"{{item.reviewWorktree}}\", name=\"{{item.name}}\", that sha, a triage block, and a " +
93
+ "gate block only if you agree deleting that tip's work loses nothing. Do not re-review; " +
94
+ "just record the missing verdicts, then stop."
95
+
96
+ /** Per-tip store lookups for one review item — `branch_gc_verdict_get` answers
97
+ * `{ sha, found, missing, record }` without re-running a whole plan. A
98
+ * branch-name item can carry SEVERAL tips (local + remote twins that
99
+ * diverged), so the check is a sequential (parallelism 1) inner map over the
100
+ * item's `allTips` tip objects, one lookup per tip; the map's own result is
101
+ * the array of those answers. */
102
+ const VERDICT_CHECK_MAP = id => ({
103
+ id,
104
+ kind: "map",
105
+ over: "$item.allTips",
106
+ parallelism: 1,
107
+ steps: [
108
+ {
109
+ id: `${id}Get`,
110
+ kind: "tool",
111
+ tool: "branch_gc_verdict_get",
112
+ inputs: { repoRoot: "$steps.branchGcPlan.plan.repoRoot", sha: "$item.sha" },
113
+ },
114
+ ],
115
+ })
116
+
117
+ /** Fold the check map's per-tip answers into the item's verdict state. Reads
118
+ * `$steps.<checkId>` — safe ONLY here, in the transform that runs IMMEDIATELY
119
+ * after this item's own check map wrote that slot (no await between write and
120
+ * read, so a concurrent sibling item cannot interleave) — then parks the
121
+ * result on `$item` (`verdictMissing`, `missingShas`), which is exclusively
122
+ * this item's, so the branch/prompt that follow never race a sibling's
123
+ * overwrite of the shared `$steps` slot. */
124
+ const VERDICT_NEEDS = (id, checkId) => ({
125
+ id,
126
+ kind: "transform",
127
+ compute: b => {
128
+ const answers = Array.isArray(b.steps[checkId]) ? b.steps[checkId] : []
129
+ const missingShas = answers.filter(r => r?.missing === true).map(r => r.sha)
130
+ b.item.verdictMissing = missingShas.length > 0
131
+ b.item.missingShas = missingShas
132
+ return { missing: missingShas.length > 0, missingShas }
133
+ },
134
+ })
135
+
136
+ /** One `review`-class branch_gc plan entry per BRANCH NAME — a local branch
137
+ * and its remote twin share one review instead of costing the reviewer two
138
+ * turns, whether they share a tip (grouped by sha, as before) or DIVERGED
139
+ * (`foo` @ newer sha, `origin/foo` @ older sha: one reviewer inspects both
140
+ * tips — the older one may carry work the newer does not). Mirrors
141
+ * `branchReviewQueue()` (`packages/worktree/src/branch-gc.ts`), reimplemented
142
+ * here because that function isn't exposed through the `branch_gc` MCP tool
143
+ * (only the CLI's `review-queue` subcommand calls it directly) — including
144
+ * its skip of a tip that already carries a stored verdict (verdicts are
145
+ * keyed by sha, so only tips that moved get re-reviewed).
146
+ *
147
+ * The merged item keeps the NEWEST unreviewed tip as its primary `sha`
148
+ * (with that tip's coverage fields) and carries the older unreviewed tips
149
+ * as `otherTips` (`[{ sha, refs }]`, newest first); `allTips` lists every
150
+ * unreviewed tip and `refs` is the union across them. A name is
151
+ * "already reviewed" — and left out of the queue — only when EVERY review
152
+ * tip under that name carries a verdict; a partially reviewed name stays
153
+ * queued with only its unreviewed tips. Returns the queue (oldest first,
154
+ * then the larger residual) plus how many names were skipped as already
155
+ * reviewed. */
156
+ export function buildReviewQueue(branchGcPlanResult) {
157
+ const plan = branchGcPlanResult?.plan
158
+ const entries = Array.isArray(plan?.entries) ? plan.entries : []
159
+ const bySha = new Map()
160
+ const dates = new Map()
161
+ for (const e of entries) {
162
+ if (e.class !== "review") continue
163
+ dates.set(e.sha, e.date ?? "")
164
+ if (e.verdict) continue
165
+ const seen = bySha.get(e.sha)
166
+ if (seen) {
167
+ seen.refs.push(e.ref)
168
+ continue
169
+ }
170
+ bySha.set(e.sha, {
171
+ name: e.name,
172
+ kind: e.kind,
173
+ sha: e.sha,
174
+ ageDays: e.ageDays,
175
+ author: e.author,
176
+ subject: e.subject,
177
+ history: e.history,
178
+ base: plan?.baseSha ?? null,
179
+ // branch_gc sets `compareBase` on EVERY unmerged entry (base itself for
180
+ // a current-history tip, the anchor for a pre-rewrite one) — only the
181
+ // pre-rewrite anchor is news to the reviewer, and the prompt's
182
+ // "pre-rewrite history" note keys off this field.
183
+ compareBase: e.history === "pre-rewrite" ? (e.compareBase ?? null) : null,
184
+ mergeBase: e.mergeBase ?? null,
185
+ mergedTree: e.mergedTree ?? null,
186
+ conflicts: e.conflicts ?? null,
187
+ ahead: e.ahead,
188
+ behind: e.behind,
189
+ pushed: e.pushed ?? null,
190
+ coverage: e.coverage ?? null,
191
+ residualFiles: e.residualFiles ?? [],
192
+ residualFileCount: e.residualFileCount ?? 0,
193
+ refs: [e.ref],
194
+ })
195
+ }
196
+ // Merge candidates whose refs share one branch NAME: `foo` and `origin/foo`
197
+ // with different tips were two queue items and two reviewers (4 of 40
198
+ // reviewer turns wasted in dogfood run 4).
199
+ const byName = new Map()
200
+ for (const c of bySha.values()) {
201
+ const sibs = byName.get(c.name)
202
+ if (sibs) sibs.push(c)
203
+ else byName.set(c.name, [c])
204
+ }
205
+ const queue = []
206
+ for (const sibs of byName.values()) {
207
+ sibs.sort((a, b) => String(dates.get(b.sha) ?? "").localeCompare(String(dates.get(a.sha) ?? "")))
208
+ const [primary, ...rest] = sibs
209
+ // Every item carries `allTips` (the verdict-check map iterates it); a
210
+ // single-tip item's `allTips` is just itself, and `otherTips` appears
211
+ // only when there IS an older tip (the prompt's extra section keys off it).
212
+ primary.allTips = sibs.map(c => ({ sha: c.sha, refs: c.refs }))
213
+ if (rest.length > 0) {
214
+ primary.otherTips = rest.map(c => ({ sha: c.sha, refs: c.refs }))
215
+ primary.refs = sibs.flatMap(c => c.refs)
216
+ }
217
+ queue.push(primary)
218
+ }
219
+ queue.sort(
220
+ (a, b) =>
221
+ (a.ageDays ?? Number.POSITIVE_INFINITY) - (b.ageDays ?? Number.POSITIVE_INFINITY) ||
222
+ (b.residualFileCount ?? 0) - (a.residualFileCount ?? 0),
223
+ )
224
+ // A name is "already reviewed" only when EVERY review tip under it carries
225
+ // a verdict — a name with one reviewed and one unreviewed tip is still
226
+ // queued (with only the unreviewed tips).
227
+ const pendingNames = new Set(queue.map(c => c.name))
228
+ const fullyReviewed = new Set()
229
+ for (const e of entries) {
230
+ if (e.class === "review" && e.verdict && !pendingNames.has(e.name)) fullyReviewed.add(e.name)
231
+ }
232
+ return { queue, alreadyReviewed: fullyReviewed.size }
233
+ }
234
+
235
+ /** `maxReviews` as a non-negative integer, else the default. */
236
+ function reviewCap(input) {
237
+ const n = input?.maxReviews
238
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_MAX_REVIEWS
239
+ }
240
+
241
+ /** `names` as inline code, at most `cap` of them, then `… and N more`. */
242
+ export function formatNameList(names, cap = REPORT_LIST_CAP) {
243
+ const shown = names.slice(0, cap).map(n => `\`${n}\``).join(", ")
244
+ return names.length > cap ? `${shown}, … and ${names.length - cap} more` : shown
245
+ }
246
+
247
+ /** Distinct error messages of a tolerant fan-out's rejected items, most
248
+ * frequent first: `[{ error, count }]`. */
249
+ export function tallyReviewErrors(review) {
250
+ const counts = new Map()
251
+ for (const r of Array.isArray(review?.results) ? review.results : []) {
252
+ if (r?.status !== "rejected") continue
253
+ counts.set(r.error, (counts.get(r.error) ?? 0) + 1)
254
+ }
255
+ return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count)
256
+ }
257
+
258
+ /** Shas of candidates a tolerant fan-out never started (spawn circuit
259
+ * breaker open) — not reviewed, which is not the same as a gap. */
260
+ function skippedShas(reviewCandidates, review) {
261
+ const out = new Set()
262
+ for (const r of Array.isArray(review?.results) ? review.results : []) {
263
+ if (r?.status === "skipped") {
264
+ const c = reviewCandidates?.[r.index]
265
+ if (c) out.add(c.sha)
266
+ }
267
+ }
268
+ return out
269
+ }
270
+
271
+ /** Candidate tips from the FIRST plan that still have no recorded verdict
272
+ * after the review map ran — `classifyRef` (branch-gc.ts) stamps
273
+ * `entry.verdict` for ANY unmerged ref with a stored verdict for its exact
274
+ * tip sha, regardless of `includeReviewed`, so a second plan is a cheap,
275
+ * accurate "did every tip get reviewed" check with no separate verdict-read
276
+ * tool needed. A branch-name item reviews SEVERAL tips (primary +
277
+ * `otherTips`), so one item can yield several gaps — one per still-missing
278
+ * tip. A candidate the review map never started (circuit open) is not a
279
+ * gap — it's reported as not reviewed. */
280
+ export function computeReviewGaps(reviewCandidates, branchGcVerifyResult, review) {
281
+ const afterEntries = branchGcVerifyResult?.plan?.entries
282
+ const verdictShas = new Set(
283
+ (Array.isArray(afterEntries) ? afterEntries : [])
284
+ .filter(e => e.verdict)
285
+ .map(e => e.sha),
286
+ )
287
+ const skipped = skippedShas(reviewCandidates, review)
288
+ const gaps = []
289
+ for (const c of reviewCandidates ?? []) {
290
+ const refsBySha = new Map((c.allTips ?? [{ sha: c.sha }]).map(t => [t.sha, t.refs ?? c.refs]))
291
+ for (const [sha, refs] of refsBySha) {
292
+ if (!verdictShas.has(sha) && !skipped.has(sha)) gaps.push({ name: c.name, sha, refs })
293
+ }
294
+ }
295
+ return gaps
296
+ }
297
+
298
+ function countOutcomes(outcomes, result) {
299
+ return (Array.isArray(outcomes) ? outcomes : []).filter(o => o.result === result).length
300
+ }
301
+
302
+ /** `{ key: count }` over `values`, in first-seen order. */
303
+ function tally(values) {
304
+ const counts = {}
305
+ for (const v of values) counts[v] = (counts[v] ?? 0) + 1
306
+ return counts
307
+ }
308
+
309
+ function formatTally(counts) {
310
+ return Object.entries(counts).map(([k, n]) => `${k}=${n}`).join(" ")
311
+ }
312
+
313
+ /** Per-class counts of a `worktree_gc` DRY RUN. The tool returns
314
+ * `{ mode: "plan", plan: GcPlanEntry[] }` — `plan` is the entry array itself
315
+ * (`makeWorktreeGcRunner`, packages/cli/src/commands/worktree.ts), NOT a
316
+ * branch_gc-style `{ plan: { entries } }` object. */
317
+ export function summarizeWorktreePlan(worktreeGcPlanResult) {
318
+ const entries = Array.isArray(worktreeGcPlanResult?.plan) ? worktreeGcPlanResult.plan : []
319
+ const byClass = { reclaim: 0, salvage: 0, hold: 0 }
320
+ for (const e of entries) byClass[e.class] = (byClass[e.class] ?? 0) + 1
321
+ return { total: entries.length, byClass }
322
+ }
323
+
324
+ /** The stored verdict of every review candidate, read off the verify plan
325
+ * (`classifyRef` stamps `entry.verdict` for any unmerged ref with a verdict
326
+ * for its exact tip) — one per candidate sha, so a local+remote twin counts
327
+ * once. */
328
+ export function collectCandidateVerdicts(reviewCandidates, branchGcVerifyResult) {
329
+ const bySha = new Map()
330
+ for (const e of branchGcVerifyResult?.plan?.entries ?? []) {
331
+ if (e.verdict && !bySha.has(e.sha)) bySha.set(e.sha, e.verdict)
332
+ }
333
+ const out = []
334
+ for (const c of reviewCandidates ?? []) {
335
+ const v = bySha.get(c.sha)
336
+ if (v) out.push({ name: c.name, sha: c.sha, triage: v.triage, agree: v.agree ?? null })
337
+ }
338
+ return out
339
+ }
340
+
341
+ export function buildReport(b) {
342
+ const wtPlan = b.steps.worktreeGcPlan
343
+ const bgPlan = b.steps.branchGcPlan
344
+ const plan = bgPlan?.plan
345
+ const summary = bgPlan?.summary
346
+ const reviewCandidates = b.steps.reviewCandidates ?? []
347
+ const reviewQueue = b.steps.reviewQueue ?? { queue: reviewCandidates, alreadyReviewed: 0 }
348
+ const review = b.steps.review
349
+ const reviewOutcome = Array.isArray(review)
350
+ ? { succeeded: review.length, failed: 0, skipped: 0 }
351
+ : { succeeded: 0, failed: 0, skipped: 0, ...(review ?? {}) }
352
+ const reviewErrors = tallyReviewErrors(review)
353
+ const gaps = b.steps.gaps ?? []
354
+ const verdicts = collectCandidateVerdicts(reviewCandidates, b.steps.branchGcVerify)
355
+ const applyMerged = b.input?.applyMerged === true
356
+ const branchGcApply = b.steps.branchGcApply
357
+ const worktreeGcApply = b.steps.worktreeGcApply
358
+
359
+ const lines = []
360
+ lines.push(`# Repo maintenance — ${plan?.repoName ?? plan?.repoRoot ?? "unknown repo"}`)
361
+ lines.push("")
362
+ lines.push(`Base \`${plan?.base ?? "?"}\` @ \`${(plan?.baseSha ?? "").slice(0, 10)}\`${plan?.anchor ? ` (anchor \`${plan.anchor.slice(0, 10)}\`)` : ""}`)
363
+ lines.push("")
364
+ lines.push("## Worktrees")
365
+ const wt = summarizeWorktreePlan(wtPlan)
366
+ lines.push(`- mode: \`${wtPlan?.mode ?? "plan"}\` — ${wt.total} worktree(s) classified`)
367
+ lines.push(`- reclaim=${wt.byClass.reclaim} salvage=${wt.byClass.salvage} hold=${wt.byClass.hold}`)
368
+ lines.push("")
369
+ lines.push("## Branches")
370
+ for (const kind of plan?.scopes ?? []) {
371
+ const c = summary?.byClass?.[kind]
372
+ if (!c) continue
373
+ lines.push(`- **${kind}**: reclaim=${c.reclaim} review=${c.review} hold=${c.hold}`)
374
+ }
375
+ if (plan?.otherRemoteRefs) lines.push(`- (${plan.otherRemoteRefs} ref(s) of other configured remotes left alone)`)
376
+ lines.push("")
377
+ lines.push(`## Review (${reviewCandidates.length} candidate(s) this run)`)
378
+ const backlog = reviewQueue.queue.length - reviewCandidates.length
379
+ lines.push(
380
+ `- queue: ${reviewQueue.queue.length} unreviewed tip(s), newest first — ${reviewCandidates.length} picked for this run` +
381
+ (backlog > 0 ? `, not reviewed this run (${backlog}) — raise \`maxReviews\` or let the next run take them` : "") +
382
+ (reviewQueue.alreadyReviewed > 0 ? `; ${reviewQueue.alreadyReviewed} tip(s) already carry a verdict from an earlier run` : ""),
383
+ )
384
+ lines.push(
385
+ `- reviewer agent turns: ${reviewOutcome.succeeded} ok, ${reviewOutcome.failed} failed` +
386
+ (reviewOutcome.skipped > 0 ? `, ${reviewOutcome.skipped} not started` : ""),
387
+ )
388
+ if (reviewOutcome.circuitOpen) {
389
+ lines.push(
390
+ `- **review stopped early — spawn circuit open** after repeated spawn failures; ` +
391
+ `${reviewOutcome.skipped} candidate(s) not reviewed this run. First error: ${reviewOutcome.circuitOpen.error}`,
392
+ )
393
+ }
394
+ if (reviewErrors.length > 0) {
395
+ lines.push(`- failure reasons (${reviewErrors.length} distinct):`)
396
+ for (const { error, count } of reviewErrors.slice(0, REPORT_LIST_CAP)) {
397
+ lines.push(` - ${count}× ${error.length > 300 ? `${error.slice(0, 300)}…` : error}`)
398
+ }
399
+ if (reviewErrors.length > REPORT_LIST_CAP) lines.push(` - … and ${reviewErrors.length - REPORT_LIST_CAP} more`)
400
+ }
401
+ if (verdicts.length > 0) {
402
+ lines.push(`- verdicts: ${formatTally(tally(verdicts.map(v => v.triage)))} (deletion agreed on ${verdicts.filter(v => v.agree === true).length})`)
403
+ }
404
+ const salvage = verdicts.filter(v => v.triage === "salvage")
405
+ if (salvage.length > 0) {
406
+ lines.push(`- **salvage — needs a human**: ${formatNameList(salvage.map(v => v.name))}`)
407
+ }
408
+ if (gaps.length > 0) {
409
+ lines.push(`- **${gaps.length} branch(es) with no recorded verdict** (after a same-session re-prompt and a large-model retry; full list in the run output's \`gaps\`): ${formatNameList(gaps.map(g => g.name))}`)
410
+ } else if (reviewCandidates.length > 0 && reviewOutcome.skipped === 0) {
411
+ lines.push("- every review candidate has a recorded verdict")
412
+ }
413
+ lines.push("")
414
+ lines.push("## Apply")
415
+ if (!applyMerged) {
416
+ lines.push("- `applyMerged` is false — dry run only, nothing was deleted.")
417
+ } else {
418
+ lines.push(
419
+ `- branch_gc: ${countOutcomes(branchGcApply?.outcomes, "deleted")} deleted, ` +
420
+ `${countOutcomes(branchGcApply?.outcomes, "failed")} failed` +
421
+ `${branchGcApply?.restoreLog ? ` — restore log: ${branchGcApply.restoreLog}` : ""}`,
422
+ )
423
+ const wtOutcomes = Array.isArray(worktreeGcApply?.outcomes) ? worktreeGcApply.outcomes : []
424
+ lines.push(`- worktree_gc: ${wtOutcomes.length} outcome(s)${wtOutcomes.length > 0 ? ` — ${formatTally(tally(wtOutcomes.map(o => o.result)))}` : ""}`)
425
+ lines.push("- only `reclaim`-class refs were touched — `includeReviewed` was false, so no reviewed-but-agreed branch was reclaimed by this run.")
426
+ }
427
+ lines.push("")
428
+ lines.push("## approve-reviewed (documented seam, not wired)")
429
+ lines.push(
430
+ "Recording a verdict via `branch_gc_verdict` never reclaims a branch by itself. " +
431
+ "A human-in-the-loop gate that reclaims `review`-class branches whose stored " +
432
+ "verdict agreed (`branch_gc`'s `includeReviewed: true`) is a deliberate, " +
433
+ "documented seam — not built here. See the repo-maintenance app README, " +
434
+ "\"Out of scope: the approval gate\".",
435
+ )
436
+ return lines.join("\n")
437
+ }
438
+
439
+ export default {
440
+ name: "Repo Maintenance",
441
+ id: "maintain",
442
+ description:
443
+ "Plan worktree_gc + branch_gc, fan a review agent out over up to " +
444
+ "`maxReviews` unreviewed branch candidates, newest first (small model for " +
445
+ "a small residual, large model otherwise), verify every candidate got a " +
446
+ "verdict, optionally apply " +
447
+ "(reclaim-only) worktree/branch gc, and report — plus an agentpush " +
448
+ "notification when `notify` is set.",
449
+ version: "0.1.0",
450
+ inputs: {
451
+ repoRoot: { type: "string", description: "Absolute path to the git repo. Wins over `workspaceSlug`." },
452
+ workspaceSlug: { type: "string", description: "Workspace slug. The active workspace when both are omitted." },
453
+ applyMerged: { type: "boolean", description: "Execute branch_gc/worktree_gc (reclaim-class only) after review. Default false.", default: false },
454
+ reviewModelSmall: { type: "string", description: `Model for a review candidate with residualFileCount <= 3. Default ${DEFAULT_REVIEW_MODEL_SMALL}.`, default: DEFAULT_REVIEW_MODEL_SMALL },
455
+ reviewModelLarge: { type: "string", description: `Model for a review candidate with residualFileCount > 3. Default ${DEFAULT_REVIEW_MODEL_LARGE}.`, default: DEFAULT_REVIEW_MODEL_LARGE },
456
+ maxReviews: { type: "number", description: `Most review candidates to review this run — newest tip first, then the larger residual. The rest are reported as not reviewed this run; tips with a stored verdict are never re-reviewed, so daily runs walk the backlog. Default ${DEFAULT_MAX_REVIEWS}.`, default: DEFAULT_MAX_REVIEWS },
457
+ notify: {
458
+ type: "object",
459
+ description: "Optional agentpush `to` target ({ channel, address }) to notify with the report when set. Omit for no notification.",
460
+ },
461
+ },
462
+ outputs: {},
463
+ steps: [
464
+ {
465
+ id: "worktreeGcPlan",
466
+ kind: "tool",
467
+ tool: "worktree_gc",
468
+ inputs: { repoRoot: "$input.repoRoot", workspaceSlug: "$input.workspaceSlug", apply: false },
469
+ },
470
+ {
471
+ id: "branchGcPlan",
472
+ kind: "tool",
473
+ tool: "branch_gc",
474
+ inputs: { repoRoot: "$input.repoRoot", workspaceSlug: "$input.workspaceSlug", apply: false, includeReviewed: false },
475
+ },
476
+ {
477
+ id: "reviewQueue",
478
+ kind: "transform",
479
+ compute: b => buildReviewQueue(b.steps.branchGcPlan),
480
+ },
481
+ {
482
+ id: "reviewCandidates",
483
+ kind: "transform",
484
+ compute: b =>
485
+ (b.steps.reviewQueue?.queue ?? [])
486
+ .slice(0, reviewCap(b.input))
487
+ .map(c => ({ ...c, reviewWorktree: reviewWorktreePath(b.steps.branchGcPlan?.plan?.repoName, c.sha) })),
488
+ },
489
+ {
490
+ id: "reviewWorktreePaths",
491
+ kind: "transform",
492
+ compute: b => (b.steps.reviewCandidates ?? []).map(c => c.reviewWorktree),
493
+ },
494
+ {
495
+ id: "review",
496
+ kind: "map",
497
+ over: "$steps.reviewCandidates",
498
+ parallelism: 4,
499
+ onError: "collect",
500
+ steps: [
501
+ REVIEW_WORKTREE("reviewWorktreeAdd", "add"),
502
+ {
503
+ id: "reviewOne",
504
+ kind: "agent",
505
+ agent: { ref: REVIEWER_REF },
506
+ cwd: REVIEWER_CWD,
507
+ prompt: REVIEW_PROMPT,
508
+ model: b => ((b.item?.residualFileCount ?? 0) <= 3
509
+ ? (b.input?.reviewModelSmall || DEFAULT_REVIEW_MODEL_SMALL)
510
+ : (b.input?.reviewModelLarge || DEFAULT_REVIEW_MODEL_LARGE)),
511
+ },
512
+ // A reviewer can end its turn announcing calls it never made. Check
513
+ // the store for EVERY tip of this item (the per-tip map below); if
514
+ // any is missing, re-prompt the same session once, then retry once
515
+ // with a fresh large-model reviewer. Only a tip still missing after
516
+ // that is a gap (see `gaps`). Every `when` below reads the step right
517
+ // before it — map items share one `$steps` namespace, and only the
518
+ // immediate predecessor's value is guaranteed to be this item's.
519
+ VERDICT_CHECK_MAP("verdictCheck"),
520
+ VERDICT_NEEDS("needsNudge", "verdictCheck"),
521
+ {
522
+ id: "needsNudgeBranch",
523
+ kind: "branch",
524
+ branches: [{ when: "$item.verdictMissing", next: "nudge" }],
525
+ join: "reviewSettled",
526
+ },
527
+ {
528
+ id: "nudge",
529
+ kind: "agent",
530
+ // The session THIS item's `reviewOne` spawned (the host labels a
531
+ // fan-out spawn `<stepId>[<index>]`).
532
+ sessionRef: "reviewOne[{{index}}]",
533
+ prompt: NUDGE_PROMPT,
534
+ },
535
+ VERDICT_CHECK_MAP("verdictCheckAfterNudge"),
536
+ VERDICT_NEEDS("needsLargeRetry", "verdictCheckAfterNudge"),
537
+ {
538
+ id: "needsLargeRetryBranch",
539
+ kind: "branch",
540
+ branches: [{ when: "$item.verdictMissing", next: "reviewRetryLarge" }],
541
+ },
542
+ {
543
+ id: "reviewRetryLarge",
544
+ kind: "agent",
545
+ agent: { ref: REVIEWER_REF },
546
+ cwd: REVIEWER_CWD,
547
+ prompt:
548
+ REVIEW_PROMPT +
549
+ "\n\nA previous reviewer of this branch ended its turn without recording a verdict. " +
550
+ "Recording the verdict with branch_gc_verdict IS the deliverable — do not end your turn without it.",
551
+ model: b => b.input?.reviewModelLarge || DEFAULT_REVIEW_MODEL_LARGE,
552
+ },
553
+ {
554
+ id: "reviewSettled",
555
+ kind: "transform",
556
+ compute: b => ({ name: b.item?.name, sha: b.item?.sha }),
557
+ },
558
+ REVIEW_WORKTREE("reviewWorktreeRemove", "remove"),
559
+ ],
560
+ },
561
+ {
562
+ // A failed item never reached its own remove — clear every review
563
+ // worktree before anything re-plans or gc's worktrees.
564
+ id: "reviewCleanup",
565
+ kind: "tool",
566
+ tool: "branch_gc_review_worktree",
567
+ inputs: { repoRoot: "$steps.branchGcPlan.plan.repoRoot", action: "remove", paths: "$steps.reviewWorktreePaths" },
568
+ },
569
+ {
570
+ id: "branchGcVerify",
571
+ kind: "tool",
572
+ tool: "branch_gc",
573
+ inputs: { repoRoot: "$input.repoRoot", workspaceSlug: "$input.workspaceSlug", apply: false, includeReviewed: false },
574
+ },
575
+ {
576
+ id: "gaps",
577
+ kind: "transform",
578
+ compute: b => computeReviewGaps(b.steps.reviewCandidates, b.steps.branchGcVerify, b.steps.review),
579
+ },
580
+ {
581
+ id: "branchGcApply",
582
+ kind: "tool",
583
+ tool: "branch_gc",
584
+ inputs: {
585
+ repoRoot: "$input.repoRoot",
586
+ workspaceSlug: "$input.workspaceSlug",
587
+ apply: "$input.applyMerged",
588
+ includeReviewed: false,
589
+ scopes: ["local", "remote", "orphan"],
590
+ },
591
+ },
592
+ {
593
+ id: "worktreeGcApply",
594
+ kind: "tool",
595
+ tool: "worktree_gc",
596
+ inputs: {
597
+ repoRoot: "$input.repoRoot",
598
+ workspaceSlug: "$input.workspaceSlug",
599
+ apply: "$input.applyMerged",
600
+ salvageDirty: false,
601
+ },
602
+ },
603
+ {
604
+ id: "report",
605
+ kind: "transform",
606
+ compute: b => buildReport(b),
607
+ },
608
+ {
609
+ id: "notifyBody",
610
+ kind: "transform",
611
+ compute: b => JSON.stringify({ to: b.input?.notify, content: { text: String(b.steps.report ?? "").slice(0, 3500) } }),
612
+ },
613
+ {
614
+ // Notify only when `notify` is set AND there is something to report
615
+ // (a reclaimable ref or a review candidate) — a daily plan-only sweep
616
+ // over an already-clean repo shouldn't page anyone. `kind:"branch"`'s
617
+ // `when` is always ref-string-resolved (never a function, unlike
618
+ // `map.over`/`agent.model` above), so the boolean itself has to be
619
+ // precomputed here rather than expressed inline.
620
+ id: "shouldNotify",
621
+ kind: "transform",
622
+ compute: b => {
623
+ if (!b.input?.notify) return false
624
+ const byClass = b.steps.branchGcPlan?.summary?.byClass ?? {}
625
+ const hasReclaimable = Object.values(byClass).some(c => (c?.reclaim ?? 0) > 0)
626
+ const hasReviewCandidates = (b.steps.reviewCandidates ?? []).length > 0
627
+ return hasReclaimable || hasReviewCandidates
628
+ },
629
+ },
630
+ {
631
+ id: "maybeNotify",
632
+ kind: "branch",
633
+ branches: [{ when: "$steps.shouldNotify", next: "notify" }],
634
+ default: "skip-notify",
635
+ },
636
+ {
637
+ id: "notify",
638
+ kind: "tool",
639
+ tool: "command_execute",
640
+ inputs: {
641
+ command: "bash",
642
+ args: [
643
+ "-c",
644
+ 'curl -s --max-time 30 -X POST -H "Authorization: Bearer ${AGENTPUSH_API_KEY:-}" ' +
645
+ '-H "Content-Type: application/json" https://api.agentpush.io/tools/send_message --data-binary @-',
646
+ ],
647
+ stdin: "$steps.notifyBody",
648
+ },
649
+ },
650
+ {
651
+ id: "skip-notify",
652
+ kind: "gate",
653
+ command: "true",
654
+ },
655
+ ],
656
+ // Always runs — success, failure or cancel: no review worktree outlives the run.
657
+ finally: [
658
+ {
659
+ id: "reviewWorktreesFinally",
660
+ kind: "tool",
661
+ tool: "branch_gc_review_worktree",
662
+ inputs: { repoRoot: "$steps.branchGcPlan.plan.repoRoot", action: "remove", paths: "$steps.reviewWorktreePaths" },
663
+ },
664
+ ],
665
+ result: {
666
+ report: "$steps.report",
667
+ gaps: "$steps.gaps",
668
+ applyMerged: "$input.applyMerged",
669
+ branchGcApply: "$steps.branchGcApply",
670
+ worktreeGcApply: "$steps.worktreeGcApply",
671
+ },
672
+ }