@bongos/core 1.19.318 → 1.19.320

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 (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/hall-ui/public/builders.js +0 -1
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/audit-replay.sh +20 -3
  11. package/scripts/gds/box.js +79 -86
  12. package/scripts/gds/db-backup-nightly.sh +17 -4
  13. package/scripts/gds/db-backup-offsite.sh +23 -6
  14. package/scripts/gds/discipline-modes.json +1 -1
  15. package/scripts/gds/doc-cli-guard.js +34 -2
  16. package/scripts/gds/fitness.js +8 -4
  17. package/scripts/gds/gds-db-backup.service +15 -4
  18. package/scripts/gds/gds-db-backup.timer +12 -1
  19. package/scripts/gds/gds-db-offsite.service +16 -5
  20. package/scripts/gds/gds-db-offsite.timer +12 -1
  21. package/scripts/gds/go-live.js +142 -48
  22. package/scripts/gds/instance-apex.js +28 -0
  23. package/scripts/gds/newcomer-floor.templates.json +2 -2
  24. package/scripts/gds/provision.js +7 -3
  25. package/scripts/gds/ship-deploy-target.js +388 -0
  26. package/scripts/gds/ship-finish.js +377 -0
  27. package/scripts/gds/ship-flow.js +814 -0
  28. package/scripts/gds/ship-git.js +100 -0
  29. package/scripts/gds/ship-grade-steps.js +376 -0
  30. package/scripts/gds/ship-grade.js +319 -0
  31. package/scripts/gds/ship-io.js +100 -0
  32. package/scripts/gds/ship-land.js +539 -0
  33. package/scripts/gds/ship-memory-sync.js +192 -0
  34. package/scripts/gds/ship-merge.js +297 -0
  35. package/scripts/gds/ship-preflight-steps.js +323 -0
  36. package/scripts/gds/ship-regen.js +286 -0
  37. package/scripts/gds/ship-resume.js +151 -0
  38. package/scripts/gds/ship-session-upload.js +216 -0
  39. package/scripts/gds/ship-state.js +73 -0
  40. package/scripts/gds/ship-surprise.js +84 -0
  41. package/scripts/gds/ship.js +105 -4195
  42. package/scripts/gds/update-channel.js +12 -0
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/go_live.mjs +65 -32
  46. package/tests/helpers.mjs +13 -0
  47. package/tests/provision.mjs +7 -1
  48. package/tests/regrade_eligibility.mjs +43 -0
  49. package/tests/ship_assurance.mjs +4 -2
  50. package/tests/ship_publish_error.mjs +43 -0
  51. package/tests/update_channel.mjs +33 -0
@@ -0,0 +1,377 @@
1
+ // scripts/gds/ship-finish.js — merge+deploy orchestration, post-ship sync/closeout and the completion card.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 3008-3352 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const { cliClient, printOnboardingNudge, rewardPolicy } = require('./cli-lib');
14
+ const { closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
15
+ const { syncMemoryOnShip } = require('./ship-memory-sync.js');
16
+ const { uploadSessionOnShip } = require('./ship-session-upload.js');
17
+ const { gitOk, shellOk } = require('./ship-git.js');
18
+ const { fetchDeployedVersion } = require('./ship-deploy-target.js');
19
+ const { landBailed, reconciledCardStatus } = require('./ship-land.js');
20
+ const { autoMerge } = require('./ship-merge.js');
21
+ const { normalizeApiError, playShipBell, postLandShipOutcome } = require('./ship-io.js');
22
+ const { getServerReward, noteServerReward } = require('./ship-state.js');
23
+
24
+ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly = false }) {
25
+ if (advanceToMerge && skipMerge) {
26
+ console.log('');
27
+ console.log('--no-merge: stopping at confirmed. Run /merge-mode later to land it.');
28
+ return landBailed('--no-merge was passed, so the land was skipped', '/merge-mode');
29
+ }
30
+ // Grade failed: the card renders the 'failed' stage ('not deployed'), so there
31
+ // is no land outcome to report.
32
+ if (!advanceToMerge) return null;
33
+
34
+ console.log('');
35
+ console.log('Auto-merge chain (confirmed → shipped):');
36
+ const merged = await autoMerge(taskId, summary, { dbOnly });
37
+ // NOTE: `merged` is an OBJECT now — every value is truthy, so this MUST test
38
+ // .ok. A bare `if (!merged)` would fall through on a bail and POST /ship for a
39
+ // task that never landed.
40
+ if (!merged.ok) return merged;
41
+
42
+ const shipBody = merged.noArtifact ? { no_artifact: true, no_artifact_reason: merged.noArtifactReason || 'deliberate empty ship (--db-only)' } : {}; // ADR 0184
43
+ const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: shipBody });
44
+ if (!ship.ok) {
45
+ // task 1049 / 1002809: the server may have already flipped this task to
46
+ // shipped (reconcile-on-read, or the reconciler racing this call). Verify
47
+ // the LIVE status before treating the refusal as a failure — the old
48
+ // flat-shape string check never matched the R01 envelope, so this success
49
+ // path was dead and every reconciled land reported as a failed ship.
50
+ const { code, message } = normalizeApiError(ship.data);
51
+ let liveStatus = null;
52
+ try {
53
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
54
+ if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
55
+ } catch (_) { /* verify is best-effort; fall back to the code-based call */ }
56
+ const outcome = postLandShipOutcome({ code, liveStatus });
57
+ if (outcome.alreadyShipped) {
58
+ console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
59
+ } else {
60
+ const e = message || code || `HTTP ${ship.status}`;
61
+ console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
62
+ // The merge itself succeeded — only the status flip failed, so a re-run
63
+ // resumes cleanly (task 941). Report it rather than letting the card claim
64
+ // the land is in progress.
65
+ return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
66
+ }
67
+ } else {
68
+ console.log(`Task #${taskId} is now: shipped`);
69
+ }
70
+
71
+ // task 1442: the task-shipped bell used to play HERE, but this point is only
72
+ // reached on a SYNCHRONOUS merge+deploy. In ci/async landing mode (now the
73
+ // norm) ship.js exits at 'confirmed' and never gets here, so the bell never
74
+ // rang. It now plays at the confirmed/shipped success milestone via
75
+ // playShipBell() (finishShip + resumeMerge), which covers both paths.
76
+
77
+ // If the builder was promoted during this ship, write the promotion flag so
78
+ // the NEXT Claude turn plays the fanfare via the Stop hook.
79
+ const TMP_FLAGS = process.platform === 'win32'
80
+ ? (process.env.TEMP || process.env.TMP || os.tmpdir())
81
+ : '/tmp';
82
+ if (ship.data?.task?.graduatedTo) {
83
+ try { fs.writeFileSync(require('path').join(TMP_FLAGS, 'otb-promotion'), ''); } catch (_) {}
84
+ console.log(` Rank promotion: ${ship.data.task.graduatedTo} — fanfare queued.`);
85
+ }
86
+
87
+
88
+ if (summary) {
89
+ // task 1749: the SERVER is now the SINGLE ship broadcaster. It emits
90
+ // task.shipped on the confirmed→shipped transition from every entry point
91
+ // (POST /ship, the publish-status reconcile-on-read, and the 5-min
92
+ // reconciler sweep — all idempotent via db.shipTask), and the discord
93
+ // module posts the #ship-feed line off that one event. This local post used
94
+ // to fire IN ADDITION to the server's, so a single ship announced FOUR
95
+ // messages (local #ship-news + #ship-feed, then the server's #ship-news +
96
+ // #ship-feed). Defer unconditionally so exactly one broadcast fires — from
97
+ // the server, after the deploy lands. (#ship-news itself is paused in
98
+ // chooseBroadcastTargets, so that one broadcast is the #ship-feed line —
99
+ // task 1749 / idea 458.) The security-sensitive fail-closed routing lives
100
+ // server-side in ship-broadcast.postShipBroadcast, so it is preserved.
101
+ console.log(' Discord broadcast: deferred to the server — it posts #ship-feed after the deploy lands.');
102
+ }
103
+ return merged;
104
+ }
105
+
106
+ // Memory + session-digest push, gated on a successful land (confirmed/shipped).
107
+ // Both are best-effort — a failure here is a warning, never a ship failure.
108
+ async function postShipSync(taskId, postGradeStatus) {
109
+ if (postGradeStatus !== 'confirmed' && postGradeStatus !== 'shipped') return null;
110
+ console.log('');
111
+ console.log('Memory sync (best-effort):');
112
+ await syncMemoryOnShip();
113
+ console.log('');
114
+ console.log('Session upload (best-effort):');
115
+ // task 1270: the upload returns { totalTokens, modelUsage } (or null) so the
116
+ // completion card can show Claude usage + an estimated cost.
117
+ return await uploadSessionOnShip(taskId);
118
+ }
119
+
120
+ // Post-ship onboarding nudge (V3.R13 #237). Best-effort — never let a stale /me
121
+ // read make the script exit non-zero after a successful ship.
122
+ async function onboardingNudgeBestEffort() {
123
+ try {
124
+ const meRes = noteServerReward(await (await cliClient()).me.getMe());
125
+ if (meRes.ok) {
126
+ console.log('');
127
+ printOnboardingNudge(meRes.data);
128
+ }
129
+ } catch (_) {
130
+ /* never block on a UX nudge */
131
+ }
132
+ }
133
+
134
+ // finishShip — the confirmed→shipped tail shared by shipMain + regradeMain (task
135
+ // 1093 / C9): auto-merge + deploy, then the best-effort memory/session sync, the
136
+ // Done line, and the onboarding nudge. Only this UNIFORM tail is shared;
137
+ // printGradeOutcome + printCreditsAndAchievements stay caller-owned (their inputs
138
+ // differ between the two paths). `doneLine` is a param so each caller keeps its
139
+ // own wording ('Done.' vs the re-grade pass/fail message).
140
+ // task 932 (Lars 2026-06-12): deterministic, no-LLM post-ship working-tree
141
+ // close-out, folded into the delivery process (NOT a separate skill). After a
142
+ // ship lands, scratch often lingers uncommitted — untracked files the preflight
143
+ // lets through, generated artifacts, post-commit edits — making the session
144
+ // hard to archive cleanly. This reports what's left and stashes it under a
145
+ // labeled, RECOVERABLE stash so the tree is left clean + archive-ready and
146
+ // nothing is lost. STRICTLY fail-open: the ship has already succeeded by the
147
+ // time this runs, so nothing here may throw, prompt, or change the exit status —
148
+ // a messy tree (or a stash failure) must never surface as a ship failure. The
149
+ // caller gates this on a SUCCESSFUL land so a grade-failed 'completed' tree the
150
+ // builder still needs to fix is never stashed out from under them.
151
+ function postShipCloseout(taskId) {
152
+ try {
153
+ const out = gitOk('git status --porcelain');
154
+ if (out == null) return; // git unavailable / not a repo — nothing to reconcile
155
+ const plan = closeoutPlan(out, taskId);
156
+ if (plan.clean) {
157
+ console.log('Close-out: working tree clean — session is archive-ready.');
158
+ return;
159
+ }
160
+ console.log('');
161
+ console.log(`Close-out: ${plan.leftover.length} uncommitted/untracked path(s) remain after the ship:`);
162
+ for (const f of plan.leftover.slice(0, 15)) console.log(` ${f}`);
163
+ if (plan.leftover.length > 15) console.log(` … and ${plan.leftover.length - 15} more`);
164
+ // --include-untracked so scratch the preflight waved through is captured too.
165
+ // Label is `otb-postship-<numericId>` — no shell metacharacters.
166
+ const stashed = shellOk(`git stash push --include-untracked -m "${plan.stashLabel}"`);
167
+ if (stashed) {
168
+ console.log(`Close-out: stashed the leftover as "${plan.stashLabel}" — tree is now clean + archive-ready.`);
169
+ console.log(' Recover anytime: git stash list → git stash pop');
170
+ } else {
171
+ console.log('Close-out: could not stash the leftover; left it in place (the ship already succeeded — tidy up manually when convenient).');
172
+ }
173
+ } catch (err) {
174
+ // Fail-open — a post-ship close-out problem must never look like a ship failure.
175
+ console.log(`Close-out: skipped (${err && err.message ? err.message : 'error'}).`);
176
+ }
177
+ }
178
+
179
+ async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false }) {
180
+ // task 1002541: the land outcome carries WHY a land didn't happen. Without it
181
+ // the card had only the status to go on and printed 'landing automatically'
182
+ // over a merge that had already bailed.
183
+ const land = await mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly });
184
+ // task 1719: mergeAndDeploy may have landed the task to 'shipped' IN-SESSION — the
185
+ // enable-success poll (pollCiDeploy) or, since task 1718, the enable-failure server
186
+ // poll (pollServerPublish). finishShip was handed postGradeStatus from the CONFIRM
187
+ // step ('confirmed'), so without re-reading it the completion card renders the stale
188
+ // 'confirmed — landing' banner AND printCompletionCard writes a pending-land
189
+ // breadcrumb — the SHIPPED banner then only appears on a LATER prompt, in whatever
190
+ // session is next (the owner-reported confusion). Re-fetch the authoritative status
191
+ // (exactly as resumeMerge does, task 1275) so a real in-session land shows the
192
+ // SHIPPED banner here and writes no breadcrumb. Best-effort: any read failure keeps
193
+ // today's behavior; the grade-fail path (advanceToMerge=false) never re-fetches.
194
+ let landedStatus = postGradeStatus;
195
+ if (advanceToMerge && (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped')) {
196
+ try {
197
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
198
+ const live = (t.ok && t.data && t.data.task) ? t.data.task.status : null;
199
+ landedStatus = reconciledCardStatus(live, postGradeStatus);
200
+ } catch (_) { /* keep postGradeStatus — the land above already awaited its outcome */ }
201
+ }
202
+ // Memory + session sync (#274 / 6D.1 / ADR 0027) — postShipSync itself gates on
203
+ // a successful land (confirmed/shipped); both legs are best-effort. It returns
204
+ // the session usage ({ totalTokens, modelUsage } | null) so the completion card
205
+ // can show Claude usage + an estimated cost (task 1270).
206
+ const usage = await postShipSync(taskId, landedStatus);
207
+ console.log('');
208
+ console.log(doneLine);
209
+ await onboardingNudgeBestEffort();
210
+ // task 1270: the ONE deterministic completion card — the same shape every ship.
211
+ // Replaces the agent's free-form prose recap (the builder-ship skill renders the
212
+ // rich in-feed card from the HTML this writes; the monospace card below is the
213
+ // always-printed fallback + the headless summary). Strictly best-effort.
214
+ // task 1719: pass the reconciled status so a just-landed ship shows SHIPPED (and
215
+ // writes no breadcrumb), not the stale pre-merge 'confirmed'.
216
+ await printCompletionCard({ taskId, postGradeStatus: landedStatus, card, usage, land });
217
+ // task 932: reconcile the working tree — ONLY on a successful land. A
218
+ // grade-failed ship stays at 'completed'; leave that tree untouched so the
219
+ // builder can fix findings + re-ship.
220
+ if (landedStatus === 'confirmed' || landedStatus === 'shipped') {
221
+ // task 1442: ring the ship bell at the success milestone. 'confirmed' counts —
222
+ // in ci/async mode the work is done + credited here and the deploy lands later
223
+ // server-side, so this is the moment the builder experiences as "shipped".
224
+ playShipBell();
225
+ postShipCloseout(taskId);
226
+ }
227
+ }
228
+
229
+ // task 1275: sum the committed diffstat (added/removed lines) over baseline...HEAD.
230
+ // Shared by the completion card and the in-review card. Best-effort: returns
231
+ // nulls when there's no baseline or git is unavailable.
232
+ function diffstatFor(baseline) {
233
+ if (!baseline) return { added: null, removed: null };
234
+ const numstat = gitOk(`git diff --numstat ${baseline}...HEAD`) || gitOk(`git diff --numstat ${baseline} HEAD`);
235
+ if (!numstat) return { added: null, removed: null };
236
+ let added = 0;
237
+ let removed = 0;
238
+ for (const line of numstat.split('\n')) {
239
+ const [a, r] = line.split('\t');
240
+ if (a !== '-' && a != null && a !== '') added += Number(a) || 0;
241
+ if (r !== '-' && r != null && r !== '') removed += Number(r) || 0;
242
+ }
243
+ return { added, removed };
244
+ }
245
+
246
+ // task 1270: build + print the deterministic completion card. Prints the
247
+ // alignment-proof monospace card to stdout (always), then writes the branded
248
+ // in-feed HTML to a temp file and prints an [otb-card-html] marker the
249
+ // builder-ship skill picks up to render it in the chat feed. Every step is
250
+ // best-effort — a summary card must NEVER fail or undo a completed ship.
251
+ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null }) {
252
+ if (!card) return;
253
+ try {
254
+ const shipCard = require('../../modules/lifecycle/ship-card');
255
+
256
+ // Diffstat from the committed range the grader used (baseline...HEAD).
257
+ const fileCount = Array.isArray(card.files) && card.files.length ? card.files.length : null;
258
+ const { added, removed } = diffstatFor(card.baseline);
259
+
260
+ // Deployed commit (shipped only): prefer prod /version, fall back to short HEAD.
261
+ let commit = null;
262
+ if (postGradeStatus === 'shipped') {
263
+ const v = fetchDeployedVersion();
264
+ commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
265
+ }
266
+
267
+ // Running drachmae total + the builder's render-widgets knob (task 1279),
268
+ // both from one /me fetch (best-effort).
269
+ let total = null;
270
+ let widgetsEnabled = true;
271
+ try {
272
+ const me = noteServerReward(await (await cliClient()).me.getMe());
273
+ if (me.ok && me.data) {
274
+ if (me.data.builder) total = me.data.builder.total_credits;
275
+ if (me.data.render) widgetsEnabled = me.data.render.widgets_enabled !== false;
276
+ }
277
+ } catch (_) { /* total + knob are optional; default widgets on */ }
278
+
279
+ // Token count + estimated cost from the session usage. Split the tokens into
280
+ // 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
281
+ // (cache_read — the same large context re-read every turn) so the card can
282
+ // lead with cost and not with a single all-in count that cache re-reads make
283
+ // look alarmingly huge (task 1765). The per-model breakdown is already on hand
284
+ // via usage.modelUsage — no new server data needed.
285
+ let tokens = null;
286
+ let costUsd = null;
287
+ let newTokens = null;
288
+ let cachedTokens = null;
289
+ if (usage) {
290
+ tokens = usage.totalTokens;
291
+ const mu = usage.modelUsage || {};
292
+ let cache = 0;
293
+ let fresh = 0;
294
+ let sawUsage = false;
295
+ for (const u of Object.values(mu)) {
296
+ if (!u) continue;
297
+ sawUsage = true;
298
+ cache += Number(u.cache_read) || 0;
299
+ fresh += (Number(u.input) || 0) + (Number(u.output) || 0) + (Number(u.cache_creation) || 0);
300
+ }
301
+ if (sawUsage) { newTokens = fresh; cachedTokens = cache; }
302
+ try {
303
+ const { priceModelUsage } = require('../../src/bongos/llm-pricing');
304
+ costUsd = priceModelUsage(mu).true_cost_usd;
305
+ } catch (_) { /* cost is optional */ }
306
+ }
307
+
308
+ const cardInputs = {
309
+ postGradeStatus,
310
+ taskId,
311
+ title: card.title,
312
+ valueSummary: card.valueSummary,
313
+ notes: card.notes,
314
+ fileCount,
315
+ added,
316
+ removed,
317
+ grade: card.grade || null,
318
+ credits: { awarded: card.creditsAwarded, total, perTaskCredit: rewardPolicy(getServerReward()).perTaskCredit },
319
+ usage: { tokens, costUsd, newTokens, cachedTokens },
320
+ deploy: { commit },
321
+ // task 1002541: absent (the default) ⇒ the card behaves exactly as before,
322
+ // which keeps every other caller — resumeMerge, the claim card, and
323
+ // land-watch replaying breadcrumbs written by older versions — unchanged.
324
+ land,
325
+ };
326
+ const model = shipCard.buildCardModel(cardInputs);
327
+
328
+ // Print the monospace card + write the branded HTML for the in-feed renderer
329
+ // (shared emitCard, task 1275). The skill reads the printed path and passes
330
+ // the contents to the visualization tool — the agent authors nothing, so the
331
+ // card is identical every ship. task 1279: when the builder has widgets off,
332
+ // emitCard prints only the monospace card (no HTML written or relayed).
333
+ shipCard.emitCard(model, taskId, { widgetsEnabled });
334
+
335
+ // task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
336
+ // confirmed->shipped land happens minutes later, server-side — so the SHIPPED
337
+ // banner would never render in-session. Drop a pending-land breadcrumb; the
338
+ // land-watch hook renders the shipped card on a later prompt once it lands.
339
+ // Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
340
+ if (postGradeStatus === 'confirmed') {
341
+ try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardInputs }); }
342
+ catch (_) { /* breadcrumb is best-effort */ }
343
+ }
344
+ } catch (_) {
345
+ /* a summary card must never fail a ship */
346
+ }
347
+
348
+ // task 1002521: the card above says SHIPPED, which means "merged" — it does
349
+ // NOT mean users can see it. Publishing is manual and owner-gated, so a ship
350
+ // can land on main and change nothing live, and until now nothing said so.
351
+ // Blocker 1000097: 171 commits (a whole governance system) sat unreleased for
352
+ // ten days while every layer reported success. Warn, never block — a shipped
353
+ // task is still shipped, and an offline builder must not be punished for it.
354
+ try {
355
+ const { check, report } = require('./deploy-divergence');
356
+ const r = await check();
357
+ if (!r.undetermined && !r.ok) report(r);
358
+ } catch (_) {
359
+ /* a divergence warning must never fail a ship */
360
+ }
361
+ }
362
+
363
+ // Decide whether a builder may re-grade a task via `--regrade` (#678). Pure so
364
+ // it's unit-testable. A re-grade is allowed iff the task is stuck at `completed`
365
+ // (a ship whose grade failed) AND the caller either last held the claim or is an
366
+ // Archon. Every other status gets a tailored, actionable message. Note this is a
367
+ // client-side UX guard, not a trust boundary: /tasks/:id/grade is rank-open by
368
+ // design (the subagent grade is the authority, not builder identity).
369
+
370
+ module.exports = {
371
+ diffstatFor,
372
+ finishShip,
373
+ mergeAndDeploy,
374
+ onboardingNudgeBestEffort,
375
+ postShipSync,
376
+ printCompletionCard,
377
+ };