@agentproto/apps 0.11.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.
- package/dist/agents-overview/panel.mjs +39 -67
- package/dist/agents-overview/panel.mjs.map +1 -1
- package/dist/agents-overview.mjs +38 -68
- package/dist/agents-overview.mjs.map +1 -1
- package/dist/bin/sync.d.ts.map +1 -1
- package/dist/bin/sync.mjs +2374 -2
- package/dist/bin/sync.mjs.map +1 -1
- package/dist/bureau-sessions/panel.mjs +39 -67
- package/dist/bureau-sessions/panel.mjs.map +1 -1
- package/dist/bureau-sessions.mjs +38 -68
- package/dist/bureau-sessions.mjs.map +1 -1
- package/dist/config/fragment.d.ts +50 -0
- package/dist/config/fragment.d.ts.map +1 -0
- package/dist/config/index.d.ts +23 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/ui.d.ts +43 -0
- package/dist/config/ui.d.ts.map +1 -0
- package/dist/config.mjs +2373 -0
- package/dist/config.mjs.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +3200 -150
- package/dist/index.mjs.map +1 -1
- package/dist/live-session/logic.d.ts +2 -0
- package/dist/live-session/logic.d.ts.map +1 -1
- package/dist/live-session/panel.d.ts.map +1 -1
- package/dist/live-session/panel.mjs +52 -74
- package/dist/live-session/panel.mjs.map +1 -1
- package/dist/live-session.mjs +51 -75
- package/dist/live-session.mjs.map +1 -1
- package/dist/panel-bridge.d.ts +11 -6
- package/dist/panel-bridge.d.ts.map +1 -1
- package/dist/session-chat/blob-embed.d.ts +53 -0
- package/dist/session-chat/blob-embed.d.ts.map +1 -0
- package/dist/session-chat/index.d.ts +13 -0
- package/dist/session-chat/index.d.ts.map +1 -1
- package/dist/session-chat/panel.d.ts +38 -2
- package/dist/session-chat/panel.d.ts.map +1 -1
- package/dist/session-chat/panel.mjs +556 -6
- package/dist/session-chat/panel.mjs.map +1 -1
- package/dist/session-chat.mjs +591 -14
- package/dist/session-chat.mjs.map +1 -1
- package/dist/session-story/panel.mjs +39 -67
- package/dist/session-story/panel.mjs.map +1 -1
- package/dist/session-story.mjs +38 -68
- package/dist/session-story.mjs.map +1 -1
- package/dist/session-viewer/ui.d.ts.map +1 -1
- package/dist/session-viewer.mjs +10 -0
- package/dist/session-viewer.mjs.map +1 -1
- package/dist/sessions-panel/panel.d.ts.map +1 -1
- package/dist/sessions-panel/panel.mjs +40 -67
- package/dist/sessions-panel/panel.mjs.map +1 -1
- package/dist/sessions-panel.mjs +39 -68
- package/dist/sessions-panel.mjs.map +1 -1
- package/dist/work-board/panel.d.ts +1 -1
- package/dist/work-board/panel.d.ts.map +1 -1
- package/dist/work-board/panel.generated.d.ts +1 -1
- package/dist/work-board/panel.generated.d.ts.map +1 -1
- package/dist/work-board/panel.mjs +318 -62
- package/dist/work-board/panel.mjs.map +1 -1
- package/dist/work-board.mjs +318 -62
- package/dist/work-board.mjs.map +1 -1
- package/package.json +12 -4
- package/repo-maintenance/.agentproto/APP.md +22 -0
- package/repo-maintenance/.agentproto/agents/repo-maintenance-reviewer/AGENT.md +142 -0
- package/repo-maintenance/.agentproto/workflows/maintain/WORKFLOW.md +262 -0
- package/repo-maintenance/.agentproto/workflows/maintain/entry.mjs +672 -0
- package/repo-maintenance/README.md +79 -0
- package/repo-maintenance/routines/repo-maintenance-daily/ROUTINE.md +77 -0
- 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
|
+
}
|