@bongos/core 1.19.319 → 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 (47) hide show
  1. package/.bongos-core.json +121 -36
  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/instance-apex.js +28 -0
  22. package/scripts/gds/newcomer-floor.templates.json +2 -2
  23. package/scripts/gds/provision.js +7 -3
  24. package/scripts/gds/ship-deploy-target.js +388 -0
  25. package/scripts/gds/ship-finish.js +377 -0
  26. package/scripts/gds/ship-flow.js +814 -0
  27. package/scripts/gds/ship-git.js +100 -0
  28. package/scripts/gds/ship-grade-steps.js +376 -0
  29. package/scripts/gds/ship-grade.js +319 -0
  30. package/scripts/gds/ship-io.js +100 -0
  31. package/scripts/gds/ship-land.js +539 -0
  32. package/scripts/gds/ship-memory-sync.js +192 -0
  33. package/scripts/gds/ship-merge.js +297 -0
  34. package/scripts/gds/ship-preflight-steps.js +323 -0
  35. package/scripts/gds/ship-regen.js +286 -0
  36. package/scripts/gds/ship-resume.js +151 -0
  37. package/scripts/gds/ship-session-upload.js +216 -0
  38. package/scripts/gds/ship-state.js +73 -0
  39. package/scripts/gds/ship-surprise.js +84 -0
  40. package/scripts/gds/ship.js +105 -4195
  41. package/scripts/status-probe.mjs +30 -2
  42. package/src/module-api.js +3 -1
  43. package/tests/helpers.mjs +13 -0
  44. package/tests/provision.mjs +7 -1
  45. package/tests/regrade_eligibility.mjs +43 -0
  46. package/tests/ship_assurance.mjs +4 -2
  47. package/tests/ship_publish_error.mjs +43 -0
@@ -0,0 +1,814 @@
1
+ // scripts/gds/ship-flow.js — the two entry flows — shipMain and regradeMain.
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 3478-4259 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 { cliClient, requireSession, arg } = require('./cli-lib');
12
+ const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
13
+ const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
+ const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
15
+ const { matchOne } = require('../../src/bongos/path-match');
16
+ const { runShipGate } = require('./sandbox-stage');
17
+ const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js');
18
+ const { gitOk } = require('./ship-git.js');
19
+ const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
20
+ const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
21
+ const { buildPriorRoundContext, computeGradeResult, gradeAndRecord, printCreditsAndAchievements, printGradeOutcome } = require('./ship-grade-steps.js');
22
+ const { apiErrorLine, normalizeApiError } = require('./ship-io.js');
23
+ const { diffstatFor, finishShip } = require('./ship-finish.js');
24
+ const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge } = require('./ship-resume.js');
25
+ const { noteServerReward } = require('./ship-state.js');
26
+
27
+ // ---------- Main flow ----------
28
+
29
+ async function shipMain() {
30
+ await requireSession();
31
+ const args = process.argv.slice(2);
32
+ const taskId = Number(args[0]);
33
+ if (!Number.isFinite(taskId)) {
34
+ console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
35
+ console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
36
+ process.exit(2);
37
+ }
38
+ // G04: prose from a FILE or inline, never shell-mangled in silence.
39
+ const prose = resolveProse(args, ['notes', 'summary']);
40
+ const notes = prose.notes;
41
+ const summary = prose.summary;
42
+ const skipMerge = args.includes('--no-merge');
43
+ const force = args.includes('--force');
44
+ // task 391 (idea #37): a deliberate zero-commit / direct-SQL ship. Without it,
45
+ // a branch with no commits ahead of main is REFUSED (not silently shipped).
46
+ // task 1002422: --base <ref> also implies db-only — the work is already on main
47
+ // (that's the whole point of --base), so the merge leg has nothing to merge and
48
+ // advances straight to shipped. Unlike --allow-empty it does NOT skip the grade:
49
+ // the pinned baseline gives the grader a real diff (see resolveBaseRef / main).
50
+ const baseRefRaw = arg('--base', args);
51
+ const dbOnly = args.includes('--db-only') || args.includes('--allow-empty') || Boolean(baseRefRaw);
52
+ // task 1053 (F): explicit attestation that a verification task's verify
53
+ // actually passed. Required to confirm a kind='verify' task (the server
54
+ // enforces it too); a no-op for every other task.
55
+ const verified = args.includes('--verified');
56
+ // #853 (ADR 0041): `let` not `const` — when the server has the temporary
57
+ // global grader bypass ON (surfaced as me.grader_bypass below), we force the
58
+ // skip-grade/confirm path for everyone so no builder runs the subagent grader
59
+ // during a grader outage window.
60
+ let skipGrade = args.includes('--skip-grade');
61
+ // task 1365: explicit acknowledgement that this task legitimately has NO
62
+ // committed artifact (a pure DB-only / no-op task). Required for the
63
+ // empty-diff guard below to ALLOW an empty committed diff; without it, an
64
+ // empty diff is refused (it used to slip through under --skip-grade — the
65
+ // task-1363 phantom ship). Never overrides a dirty working tree.
66
+ const allowEmpty = args.includes('--allow-empty');
67
+ // task 1002605 (G-18): explicit opt-in to continue past a FAILED production
68
+ // smoke run. Without it a smoke FAIL now ABORTS before the claim resolves
69
+ // (claim intact); with it, the resolve proceeds and the task deliberately
70
+ // parks at 'completed' ungraded — and the ship says so instead of "Done."
71
+ const allowSmokeFail = args.includes('--allow-smoke-fail');
72
+ // Surprise flag handling — see captureSurpriseLearning(). Resolved below
73
+ // once we know which claim is being shipped (so touches[] can become tags).
74
+ const surpriseRaw = arg('--surprise', args);
75
+ const surpriseProvided = surpriseRaw !== null;
76
+ const noSurprise = args.includes('--no-surprise');
77
+
78
+ // task 1002422 (blocker 1000082): resolve + validate --base BEFORE any heavy
79
+ // work so a bad ref aborts with the claim STILL ACTIVE. On success it sets the
80
+ // module-level baseOverrideSha that computeGraderBaseline() reads, so the grader
81
+ // reads <base>...HEAD (a real diff) and the ship takes the rank-open graded path
82
+ // instead of the --allow-empty → skip-grade → Archon-only /confirm dead-end.
83
+ if (baseRefRaw) {
84
+ const res = applyBaseOverride(baseRefRaw);
85
+ if (!res.ok) {
86
+ console.error(`Ship aborted: ${res.reason}. No claim resolved, no credits awarded.`);
87
+ console.error('Omit --base and ship normally — the grader reviews ALL your commits and the merge lands them. Your claim is still active.');
88
+ process.exit(2);
89
+ }
90
+ console.log(`--base: grading ${res.sha.slice(0, 9)}...HEAD — the task's real committed diff (work already on main). Graded via the rank-open path; no --allow-empty needed.`);
91
+ }
92
+
93
+ const me = noteServerReward(await (await cliClient()).me.getMe());
94
+ if (!me.ok) {
95
+ console.error('failed to load profile:', me.data);
96
+ process.exit(1);
97
+ }
98
+ // #853 (ADR 0041): honor the server-side global grader bypass. When ON, every
99
+ // builder lands work without the subagent grader (the /tasks/:id/confirm gate
100
+ // is relaxed to any-builder server-side to match). Default OFF.
101
+ if (me.data.grader_bypass && !skipGrade) {
102
+ skipGrade = true;
103
+ console.log('Note: GRADER_BYPASS_ENABLED is on server-side — skipping the subagent grader and confirming directly (temporary outage window).');
104
+ }
105
+ const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
106
+ const ac = claims.find((c) => Number(c.task_id) === taskId);
107
+ if (!ac) {
108
+ // task 941 (V4.R03): no active claim in THIS session is NOT automatically a
109
+ // dead-end. The claim is consumed at resolve, so a ship that stalled AFTER it
110
+ // (grade fail → `completed`; merge/deploy fail → `confirmed`, the #1027 case)
111
+ // leaves no claim — but a plain re-run should RESUME the stalled leg, not
112
+ // strand the task. Route on the task's current status + ownership.
113
+ const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'last_claim' } });
114
+ const task = tRes.ok ? tRes.data.task : null;
115
+ const lastClaim = tRes.ok ? (tRes.data.last_claim || null) : null;
116
+ const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
117
+ if (action.action === 'resume-grade') {
118
+ console.log(`Task #${taskId} is at 'completed' (grade stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the grade (no re-claim needed).`);
119
+ return regradeMain();
120
+ }
121
+ if (action.action === 'resume-merge') {
122
+ console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
123
+ return resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly });
124
+ }
125
+ if (action.action === 'done') {
126
+ console.log(action.reason);
127
+ return;
128
+ }
129
+ console.error(action.reason || `You don't have an active claim on task #${taskId}.`);
130
+ if (claims.length > 0) {
131
+ console.error(`Your active claims:`);
132
+ for (const c of claims) console.error(` #${c.task_id}: ${c.title}`);
133
+ }
134
+ process.exit(1);
135
+ }
136
+
137
+ // ---- Hard cap: refuse to ship over the monthly budget (V3.R84 #302, C3) ----
138
+ // Shipping runs the subagent grader, which spends API $. If this builder is at
139
+ // or over their monthly budget, refuse here — BEFORE the grader runs and BEFORE
140
+ // the claim resolves, so the claim stays active and no cost is incurred. Any
141
+ // Metic+ builder lifts the block by raising the builder's budget
142
+ // (PATCH /builders/:id/budget — builder.budget.set, metic-floored since ADR 0157).
143
+ // Fail-open: a missing/unreadable cost status (e.g. an older server) never blocks a ship.
144
+ const costStatus = me.data.cost;
145
+ if (costStatus && costStatus.level === 'over') {
146
+ console.error('');
147
+ console.error(`Ship refused: monthly budget exceeded — $${costStatus.spend_mtd_usd} of $${costStatus.budget_mtd_usd} spent this month (${costStatus.pct_used}%).`);
148
+ console.error('Shipping runs the quality grader, which spends API budget. No claim resolved, no credits awarded.');
149
+ console.error('Ask any Metic or Archon to raise your monthly budget (PATCH /builders/:id/budget), then re-ship.');
150
+ process.exit(1);
151
+ }
152
+
153
+ // ---- Surprise gate: forced "anything surprise you?" prompt ----
154
+ //
155
+ // Resolve the user's intent into a single value: either a non-empty surprise
156
+ // string (capture a learning) or empty (no surprise, ship normally).
157
+ // --surprise "text" → capture
158
+ // --surprise none → no surprise
159
+ // --surprise "" → no surprise
160
+ // --no-surprise → no surprise
161
+ // neither + TTY → prompt
162
+ // neither + non-TTY → no surprise, with stderr warning
163
+ let surpriseText = '';
164
+ if (surpriseProvided) {
165
+ const v = (surpriseRaw || '').trim();
166
+ surpriseText = (v === '' || v.toLowerCase() === 'none') ? '' : v;
167
+ } else if (noSurprise) {
168
+ surpriseText = '';
169
+ } else if (process.stdout.isTTY && process.stdin.isTTY) {
170
+ surpriseText = await promptSurprise();
171
+ if (surpriseText.toLowerCase() === 'none') surpriseText = '';
172
+ } else {
173
+ console.error('warn: no --surprise or --no-surprise provided in non-interactive context; defaulting to no surprise.');
174
+ surpriseText = '';
175
+ }
176
+
177
+ // ---- Step 0: preflight (working tree + branch freshness) ----
178
+ console.log(`Verifying claim #${ac.claim_id} on task #${ac.task_id}: ${ac.title}`);
179
+ console.log('Preflight (working tree + branch freshness)...');
180
+ const pre = preflight(ac, { force });
181
+ if (!pre.ok) {
182
+ console.error('');
183
+ console.error(`Ship aborted: ${pre.reason}. No claim resolved, no credits awarded.`);
184
+ process.exit(1);
185
+ }
186
+
187
+ // ---- Step 0b: pull origin/main into the branch up front (#1068, gap 2) ----
188
+ // So the tree reviewed on the sandbox == the tree we ship — no post-approval
189
+ // re-stage loop. Skipped under --no-merge (a deliberate "don't touch main"
190
+ // opt-out). Aborts only on a genuine conflict, claim left intact.
191
+ if (!skipMerge) {
192
+ const m = mergeMainIntoBranch();
193
+ if (!m.ok) {
194
+ console.error('');
195
+ console.error(`Ship aborted: ${m.reason}.`);
196
+ console.error('No claim resolved, no credits awarded — your claim is still active.');
197
+ process.exit(1);
198
+ }
199
+ }
200
+
201
+ // ---- Capture surprise learning (if any) ----
202
+ if (surpriseText) {
203
+ console.log('Capturing surprise as a learning...');
204
+ await captureSurpriseLearning(surpriseText, ac, taskId);
205
+ }
206
+
207
+ // ---- Pre-verify: auto-linkify this ship's own changed docs (#747) ----
208
+ // Fix bare "#NNN" task refs in the docs this ship changed BEFORE the merge,
209
+ // so they never red-X the doc-ref-linkify gate (tests/linkify_check.mjs in
210
+ // the `unit` check). Best-effort.
211
+ autoLinkifyChangedDocs(ac);
212
+
213
+ // ---- Empty-diff / phantom-ship guard (must run BEFORE the review gate + resolve) ----
214
+ // #513 added this check, but at step 2 — AFTER the resolve below already
215
+ // landed the task at `completed` and consumed the claim. A builder who ran
216
+ // /builder-ship before committing then burned the claim and couldn't re-ship
217
+ // ("You don't have an active claim" — the task-595-regrade-gap). Observed
218
+ // live on #662 (Windows). Moving it here means an empty committed diff aborts
219
+ // cleanly with the claim STILL ACTIVE, so the builder can commit and re-run.
220
+ // The grader reads committed history (baseline...HEAD), independent of
221
+ // touches[] (see computeGraderBaseline). The computed list is reused by the
222
+ // grader at step 2 so we don't diff twice.
223
+ //
224
+ // task 1365: this guard now runs REGARDLESS of --skip-grade. The old version
225
+ // was gated on `!skipGrade`, so --skip-grade (and the server-side
226
+ // grader_bypass that forces skipGrade) sailed an empty committed diff straight
227
+ // through to resolve → manual-confirm → the publish reconciler then shipped a
228
+ // task with NO artifact on main (task 1363: a new doc left UNTRACKED, never
229
+ // committed, HEAD never moved — the ledger lied, CLAUDE.md §8). classifyEmptyShip
230
+ // splits the three cases: a dirty/untracked working tree means the deliverable
231
+ // was never committed (refuse, even under --allow-empty); a clean tree with an
232
+ // explicit --allow-empty is a genuine no-artifact/DB-only ship (allow); a clean
233
+ // tree without the flag is nothing-to-ship (refuse). Refusing here leaves the
234
+ // claim ACTIVE so the builder can commit + re-run.
235
+ const graderBaseline = computeGraderBaseline(ac);
236
+ const committedFiles = computeChangedFiles(graderBaseline);
237
+ if (committedFiles.length === 0) {
238
+ const verdict = classifyEmptyShip(gitOk('git status --porcelain'), { allowEmpty });
239
+ if (verdict.reason === 'uncommitted-work') {
240
+ console.error('Ship refused: nothing is committed on this branch, but your working tree has uncommitted changes:');
241
+ for (const f of verdict.uncommitted) console.error(` - ${f}`);
242
+ console.error('');
243
+ console.error('Your work was never committed — shipping now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
244
+ console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run /builder-ship. Your claim is still active.');
245
+ process.exit(1);
246
+ }
247
+ if (verdict.reason === 'nothing-to-ship') {
248
+ console.error(`Nothing to ship: the committed diff vs ${graderBaseline || 'baseline'} is empty and the working tree is clean.`);
249
+ console.error('The grader reads committed history, not the working tree — commit your work on this branch, then re-run /builder-ship. Your claim is still active.');
250
+ console.error('If this task legitimately has no code/doc artifact (a pure DB-only or no-op task), re-run with --allow-empty.');
251
+ process.exit(1);
252
+ }
253
+ // verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
254
+ // There is no committed diff for the subagent grader to read, so confirm
255
+ // directly (this is the DB-only path the old --skip-grade route served).
256
+ if (!skipGrade) skipGrade = true;
257
+ console.log('--allow-empty: no committed diff (no-artifact / DB-only ship) — confirming directly without the subagent grader.');
258
+ }
259
+
260
+ // ---- Module drift check (BV1.R53, task 1422) ----
261
+ // If the task carries a module_key, verify the committed diff doesn't stray
262
+ // into a DIFFERENT module's territory. "You said game, you touched dev-box" is
263
+ // the semantic violation this catches. Files outside all module scopes (docs,
264
+ // session logs, general scripts) are allowed — they're cross-cutting. Only
265
+ // contact with a DIFFERENT module's glob set is refused.
266
+ //
267
+ // The check is ADVISORY (warns but does not refuse) on the first cut because
268
+ // the module_key column is new and existing tasks may carry a broader diff scope
269
+ // until all tasks are labelled. Enforced tasks can opt in with --enforce-module.
270
+ // ac.module_key comes from getActiveClaimsForBuilder (t.module_key in the join).
271
+ const enforceModule = args.includes('--enforce-module');
272
+ if (ac.module_key && isModuleKey(ac.module_key) && committedFiles.length > 0) {
273
+ const declaredScope = scopeForModule(ac.module_key);
274
+ const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map');
275
+ const moduleDriftFiles = committedFiles.filter((f) => {
276
+ if (declaredScope && matchOne(f, declaredScope.globs)) return false;
277
+ for (const [key, entry] of Object.entries(MODULE_SCOPE_MAP)) {
278
+ if (key === ac.module_key) continue;
279
+ if (matchOne(f, entry.globs)) return true;
280
+ }
281
+ return false;
282
+ });
283
+ if (moduleDriftFiles.length > 0) {
284
+ const msg = [
285
+ `\n⚠ Module drift detected: task is scoped to '${ac.module_key}' but ${moduleDriftFiles.length} file(s) touch a different module:`,
286
+ ...moduleDriftFiles.map((f) => ` - ${f}`),
287
+ '',
288
+ enforceModule
289
+ ? 'Refused (--enforce-module). Confine your changes to modules/' + ac.module_key + '/ or clear the module_key label.'
290
+ : 'Advisory — ship proceeds, but consider confining your changes to modules/' + ac.module_key + '/.',
291
+ ].join('\n');
292
+ console.log(msg);
293
+ if (enforceModule) process.exit(1);
294
+ }
295
+ }
296
+
297
+ // ---- Sandbox-first review gate (#927, ADR 0046) ----
298
+ // On a dev box, a diff that touches game surfaces must be STAGED on the
299
+ // builder's own live preview (sandbox-<login>.amazonprimea.com, ADR 0044)
300
+ // and reviewed in a browser before the claim resolves — see it running,
301
+ // get the chance to iterate, THEN decide to ship to the main game. Laptop
302
+ // sessions, non-game diffs, quick-tunnel boxes, and --skip-stage all pass
303
+ // through untouched. Interactive: stage + ask; declining aborts with the
304
+ // claim intact. Non-TTY (agent-driven sessions): stage, then abort with the
305
+ // review URL — the re-run after the human reviews passes on the fresh
306
+ // staged marker. Never hangs on stdin.
307
+ const sandboxGate = await runShipGate({
308
+ taskId,
309
+ files: committedFiles,
310
+ skipStage: args.includes('--skip-stage'),
311
+ // #1048: --approved records the builder's sign-off after they reviewed the
312
+ // staged change on their sandbox. Only honored on a re-run whose game state
313
+ // is already staged-fresh — it can't pre-approve an unstaged change.
314
+ approved: args.includes('--approved'),
315
+ tty: Boolean(process.stdout.isTTY && process.stdin.isTTY),
316
+ });
317
+ if (!sandboxGate.ok) {
318
+ if (sandboxGate.review) {
319
+ // task 1275: the in-review card — the SAME lifecycle card, amber, whose
320
+ // action is "Open in sandbox ↗" (the sandbox URL the owner asked to surface
321
+ // here). Best-effort, never blocks the pause.
322
+ try {
323
+ const shipCard = require('../../modules/lifecycle/ship-card');
324
+ const { added, removed } = diffstatFor(graderBaseline);
325
+ const model = shipCard.buildCardModel({
326
+ stage: 'review',
327
+ taskId,
328
+ title: ac.title,
329
+ valueSummary: summary,
330
+ fileCount: committedFiles.length || null,
331
+ added,
332
+ removed,
333
+ sandboxUrl: sandboxGate.url,
334
+ });
335
+ // task 1279: honour the builder's render-widgets knob (from the /me
336
+ // fetched at the top of shipMain; default ON if absent/older server).
337
+ const widgetsEnabled = me?.data?.render?.widgets_enabled !== false;
338
+ shipCard.emitCard(model, taskId, { widgetsEnabled });
339
+ } catch (_) { /* the review pause stands with or without the card */ }
340
+ // #1061 polish: a sandbox review pause is a DELIBERATE, expected
341
+ // checkpoint — not a failed ship. printReviewBlock already showed the URL
342
+ // + the --approved next step, so don't follow it with red "Ship aborted /
343
+ // no credits awarded" text that makes the review moment read as broken.
344
+ // Calm note on stdout (not stderr); the claim is left fully intact.
345
+ console.log('');
346
+ console.log('⏸ Paused for your sandbox review — this is expected, not an error.');
347
+ console.log(' Your claim is still active; nothing was lost.');
348
+ console.log(' Walk the change at the link above, then approve with: /builder-ship --approved');
349
+ process.exit(1);
350
+ }
351
+ console.error('');
352
+ console.error(`Ship aborted: ${sandboxGate.reason}.`);
353
+ console.error('No claim resolved, no credits awarded — your claim is still active.');
354
+ process.exit(1);
355
+ }
356
+ // #1048: the recorded sandbox self-approval (when the builder approved a
357
+ // staged game change), threaded into the resolve call below so the server
358
+ // persists it on the task. null on laptop ships, non-game diffs, quick-tunnel
359
+ // boxes, --skip-stage, and bare fresh-marker re-runs without --approved.
360
+ const sandboxReview = sandboxGate.sandboxReview || null;
361
+
362
+ // ---- Step 1+2: verify locally (smoke) — runs AFTER the review gate (#1061) ----
363
+ // touches[] cleanup [4/8] (task 879): the predictive drift scanner is gone;
364
+ // local verification is smoke-only. Smoke runs HERE (post-gate) so a review
365
+ // PAUSE doesn't bury the sandbox link under the smoke output — smoke only runs
366
+ // on the actual ship: the --approved re-run, or a laptop / non-game ship the
367
+ // gate waved straight through. (#1061 — the gate is a no-op for those, so this
368
+ // is the same single smoke run as before, just sequenced after it.)
369
+
370
+ // task 1002660: start the grade COMPUTATION now, so the multi-minute panel
371
+ // overlaps the smoke run + claim resolve instead of serializing behind them.
372
+ // computeGradeResult performs no server writes — the grade POST stays in the
373
+ // grade step below, after the resolve lands the task at 'completed'. On a
374
+ // smoke abort the in-flight panel is discarded (its partial spend is lost —
375
+ // rare, and cheaper than serializing every ship behind smoke).
376
+ let gradeCompute = null;
377
+ let fullTaskForGrade = null;
378
+ if (!skipGrade) {
379
+ const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
380
+ fullTaskForGrade = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
381
+ console.log('Starting the quality grader panel (overlaps the smoke run — task 1002660)...');
382
+ gradeCompute = computeGradeResult({
383
+ taskId, task: fullTaskForGrade, summary,
384
+ baseline: graderBaseline, committedFiles,
385
+ });
386
+ gradeCompute.catch(() => {}); // an abort path must not die on an unhandled rejection
387
+ }
388
+
389
+ console.log('Running smoke tests against production...');
390
+ // task 1002660: the async twin — awaiting keeps the event loop free so the
391
+ // overlapped panel's subprocess callbacks progress while smoke runs.
392
+ const smoke = await runSmokeAsync();
393
+ const passed = smoke.clean;
394
+ console.log('');
395
+ console.log(`Verification: smoke=${smoke.clean ? 'clean' : 'FAIL'}, overall=${passed ? 'PASS' : 'FAIL'}`);
396
+ console.log('');
397
+
398
+ // task 1002605 (G-18): a smoke FAIL used to fall straight through into the
399
+ // claim resolve — claim consumed, grading silently skipped, task parked at
400
+ // 'completed', exit 0, and the builder read "Done." believing they shipped.
401
+ // Abort BEFORE the resolve instead — the same claim-preserving posture as the
402
+ // budget cap and the sandbox-review gate above. --allow-smoke-fail is the
403
+ // deliberate continuation; the parked message where the grader would have run
404
+ // says exactly where that leaves the task.
405
+ if (!passed && !allowSmokeFail) {
406
+ console.error('');
407
+ for (const line of smokeGateMessages(taskId).abort) console.error(line);
408
+ process.exit(1);
409
+ }
410
+
411
+ // ---- Step 1: resolve the claim (lands at completed) ----
412
+ // V3.R18 (#209): resolveClaim no longer runs the v1 deterministic grader.
413
+ // It now lands the task at `completed` when verification (smoke) passes, and
414
+ // stops. The v2 subagent grader (step 2 below) is what gates the credits +
415
+ // the completed→confirmed transition.
416
+ const r = await (await cliClient()).claims.postClaimsIdResolve({
417
+ id: ac.claim_id,
418
+ body: {
419
+ outcome: 'shipped',
420
+ notes_md: notes,
421
+ value_summary: summary,
422
+ verification: {
423
+ passed,
424
+ // touches[] cleanup [4/8] (task 879): the drift scanner is gone; with no
425
+ // scanner there is nothing to drift, so this audit-trail signal
426
+ // (grader.scoreScannerSmoke) is vacuously clean.
427
+ scanner_clean: true,
428
+ smoke_clean: smoke.clean,
429
+ },
430
+ // touches[] cleanup [7/8] (task 882): the real committed changed-files, so
431
+ // the server backfills touches[] from reality (the planning prediction is
432
+ // now optional). Same list the grader already used.
433
+ committed_files: committedFiles,
434
+ // #1048: the sandbox self-approval { stamp, url, method } when the builder
435
+ // reviewed + approved a staged game change. The server stamps by/at and
436
+ // persists it to tasks.sandbox_review. null = nothing to record.
437
+ sandbox_review: sandboxReview,
438
+ },
439
+ });
440
+ if (!r.ok) {
441
+ const resolveErr = normalizeApiError(r.data);
442
+ if (resolveErr.code === 'CLAIM_SESSION_MISMATCH') {
443
+ const wt = resolveErr.details.claimWorktree;
444
+ console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
445
+ console.error('You can only ship from the session that originally claimed this task.');
446
+ console.error(`To force-release from this session: node scripts/gds/release.js ${taskId} --force`);
447
+ } else {
448
+ console.error(`resolve failed: ${apiErrorLine(r.data, r.status)}`);
449
+ }
450
+ process.exit(1);
451
+ }
452
+
453
+ const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
454
+ console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
455
+ // Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
456
+ markShipProgress('resolved', taskId);
457
+
458
+ // ---- Step 2: subagent quality grader ----
459
+ // Runs when local verification passed AND resolveClaim landed at completed.
460
+ // If verification did not pass we don't bother grading — the task stays at
461
+ // completed (or whatever resolveClaim returned) with no credits.
462
+ let credits = r.data.creditsAwarded ?? 0;
463
+ let postGradeStatus = taskStatus;
464
+ let postGradeJustUnlocked = r.data.just_unlocked || [];
465
+ let gradePassed = false;
466
+ let advanceToMerge = false;
467
+ let gradeObj = null; // task 1270: the grade result, hoisted for the completion card
468
+ // V3.R27 (#247): credit breakdown surfaced from the grade or confirm
469
+ // response. Kept separate from `credits` so we only print the multiplier
470
+ // line when the multiplier actually moved the payout.
471
+ let creditsRaw = null;
472
+ let creditsMultiplier = null;
473
+ let ideaBonusInfo = null;
474
+
475
+ if (passed && taskStatus === 'completed' && skipGrade) {
476
+ // Escape hatch: skip the subagent grader and manual-confirm. The
477
+ // task_grades row gets grader_kind='lars' so the audit trail captures
478
+ // that this ship bypassed the v2 grader.
479
+ console.log('');
480
+ console.log('--skip-grade: skipping subagent grader; manual-confirming via POST /tasks/:id/confirm.');
481
+ // task 1053 (F): pass the verify attestation. The server refuses to confirm a
482
+ // kind='verify' task without it (so a hardware-verify can't land before the
483
+ // verify ran). --verified is a no-op for every other kind.
484
+ const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
485
+ if (!confirm.ok) {
486
+ if (confirm.status === 409 && normalizeApiError(confirm.data).code === 'verify_attestation_required') {
487
+ console.error('');
488
+ console.error(`This is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
489
+ console.error(`Run the verification, then re-ship with --verified:`);
490
+ console.error(` node scripts/gds/ship.js ${taskId} --verified`);
491
+ console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
492
+ process.exit(1);
493
+ }
494
+ // task 1002613 (map P-1): the one genuine permission wall on the ship path
495
+ // used to surface as a raw envelope dump — an atom name is not a rank, and
496
+ // no remedy printed at the one station where self-service exists. Interpret
497
+ // it: name the wall, the floor (from the server's floors map — never
498
+ // hardcoded), and both ways forward.
499
+ const errObj = confirm.data && confirm.data.error;
500
+ const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
501
+ if (confirm.status === 403 && errCode === 'permission_forbidden') {
502
+ const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
503
+ const floor = details.floors && details.floors['task.confirm.grade_bypass'];
504
+ const floorNote = floor ? ` (floor: ${floor})` : '';
505
+ console.error('');
506
+ console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
507
+ console.error(' This is an authorization wall, not a verdict on your work.');
508
+ console.error(' Self-service: re-run WITHOUT --skip-grade to use the subagent grader, and if a');
509
+ console.error(' grade fails, fix its findings and re-run (unlimited, no attempt cap):');
510
+ console.error(` node scripts/gds/ship.js ${taskId} --regrade`);
511
+ console.error(` If the grade genuinely cannot pass, ask a builder whose role holds`);
512
+ console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
513
+ process.exit(1);
514
+ }
515
+ console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
516
+ process.exit(1);
517
+ }
518
+ postGradeStatus = confirm.data.taskStatus;
519
+ credits = confirm.data.creditsAwarded ?? 0;
520
+ ideaBonusInfo = confirm.data.ideaBonus ?? null;
521
+ advanceToMerge = postGradeStatus === 'confirmed';
522
+ gradePassed = true; // by builder fiat, not by grader
523
+ } else if (passed && taskStatus === 'completed') {
524
+ console.log('');
525
+ console.log('Running subagent quality grader...');
526
+
527
+ // #513 + #536: the grader reads COMMITTED history (baseline...HEAD), not
528
+ // the working tree. The empty-committed-diff guard now runs earlier — BEFORE
529
+ // the claim is resolved (see above) — so by the time we reach here the diff
530
+ // is guaranteed non-empty on the grader path. Reuse the baseline + file list
531
+ // computed there so we don't diff twice. #678: gradeAndRecord POSTs the
532
+ // result and logs the panel cost — the same unit the --regrade path reuses.
533
+ // task 1002660: the panel computation was started BEFORE the smoke gate
534
+ // (gradeCompute above) — hand it in so this step only waits out whatever
535
+ // panel time smoke + resolve didn't already absorb.
536
+ const gradePost = await gradeAndRecord({
537
+ taskId,
538
+ task: fullTaskForGrade || { id: taskId, title: ac.title },
539
+ summary,
540
+ baseline: graderBaseline,
541
+ committedFiles,
542
+ precomputed: gradeCompute,
543
+ });
544
+
545
+ if (!gradePost.ok) {
546
+ console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
547
+ console.error('Task stays at completed. Manual options:');
548
+ console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
549
+ console.error(` - node scripts/gds/ship.js ${taskId} --regrade after fixing findings, or`);
550
+ console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm (last resort —`);
551
+ console.error(' permission-gated on task.confirm.grade_bypass).');
552
+ process.exit(1);
553
+ }
554
+
555
+ const g = gradePost.data.grade;
556
+ gradeObj = g;
557
+ gradePassed = g.passed;
558
+ advanceToMerge = gradePost.data.advanceToMerge;
559
+ postGradeStatus = gradePost.data.taskStatus;
560
+ credits = gradePost.data.creditsAwarded;
561
+ postGradeJustUnlocked = gradePost.data.just_unlocked || [];
562
+ // V3.R27 (#247): pull the multiplier breakdown + idea-bonus info forward.
563
+ creditsRaw = gradePost.data.creditsRaw ?? null;
564
+ creditsMultiplier = gradePost.data.creditsMultiplier ?? null;
565
+ ideaBonusInfo = gradePost.data.ideaBonus ?? null;
566
+
567
+ printGradeOutcome(g, taskId);
568
+ } else if (!passed && taskStatus === 'completed') {
569
+ // task 1002605 (G-18): the --allow-smoke-fail continuation. The resolve above
570
+ // consumed the claim with verification.passed=false, so the grader deliberately
571
+ // does not run. This used to be a silent fall-through; say where the task is.
572
+ console.log('');
573
+ for (const line of smokeGateMessages(taskId).parked) console.log(line);
574
+ }
575
+
576
+ printCreditsAndAchievements({
577
+ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo,
578
+ justUnlocked: postGradeJustUnlocked,
579
+ });
580
+
581
+ // ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
582
+ // Done → onboarding nudge → completion card), shared with regradeMain via
583
+ // finishShip (#1093/C9). ----
584
+ // Graded. If it passed, only the merge remains — a different crash story.
585
+ if (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped') {
586
+ markShipProgress(postGradeStatus === 'shipped' ? 'landed' : 'graded', taskId);
587
+ }
588
+ await finishShip({
589
+ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
590
+ // task 1270: data for the deterministic completion card.
591
+ card: {
592
+ title: ac.title,
593
+ valueSummary: summary,
594
+ notes,
595
+ files: committedFiles,
596
+ baseline: graderBaseline,
597
+ creditsAwarded: credits,
598
+ // task 1275: a real grade ran → pass/fail + issues; otherwise (--skip-grade
599
+ // or server-side GRADER_BYPASS) it was confirmed without a grade → skipped,
600
+ // so the card reads "grade skipped" rather than implying a real pass.
601
+ grade: gradeObj
602
+ ? { passed: gradeObj.passed, issues: gradeObj.issues || [], panel: Array.isArray(gradeObj.workers) ? gradeObj.workers.length : null, skipped: false }
603
+ : { passed: gradePassed, skipped: true },
604
+ },
605
+ });
606
+ }
607
+
608
+ // ---------- Re-grade flow (#678) ----------
609
+ //
610
+ // `node scripts/gds/ship.js <id> --regrade` closes the task-595-regrade-gap
611
+ // (re-hit on #600): when a ship lands at `completed` but the subagent grader
612
+ // fails, /builder-ship has already resolved+consumed the claim, leaving no CLI
613
+ // path back in (claim refuses "not ready", PATCH refuses, promote refuses, a
614
+ // fresh ship.js says "no active claim"). The only escapes were the Archon
615
+ // /confirm override (bypasses the grader — see feedback_prefer_regrade_over_override)
616
+ // or hand-building a grader harness (learning #43).
617
+ //
618
+ // This re-runs ONLY the grade step against the committed diff, with no claim:
619
+ // - eligibility: task at `completed` AND (you last held the claim OR Archon)
620
+ // - preflight: working tree committed + ship/grade tooling current
621
+ // - empty-diff guard: the grader reads committed history, so refuse if empty
622
+ // - grade: gradeAndRecord → POST /tasks/:id/grade (rank-open; applyGrade
623
+ // promotes completed→confirmed + awards credits on pass, idempotent)
624
+ // - on pass: the normal confirmed→shipped merge + sync tail runs.
625
+ async function regradeMain() {
626
+ await requireSession();
627
+ const args = process.argv.slice(2);
628
+ const taskId = Number(args[0]);
629
+ if (!Number.isFinite(taskId)) {
630
+ console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
631
+ process.exit(2);
632
+ }
633
+ const regradeProse = resolveProse(args, ['summary', 'response']);
634
+ const summaryArg = regradeProse.summary;
635
+ // task 1002662: the builder's response to the prior round's findings — how
636
+ // each was fixed, tracked (a task ref), or why it's out of scope. Rides to
637
+ // the panel fenced, under the delta re-grade policy.
638
+ const responseArg = regradeProse.response;
639
+ const skipMerge = args.includes('--no-merge');
640
+ const force = args.includes('--force');
641
+ // task 1002422: honor --base on regrade/resume too, so `--regrade --base <ref>`
642
+ // and shipMain's resume-grade dispatch grade the real <base>...HEAD diff and
643
+ // advance already-on-main work, instead of aborting "nothing to grade". Same
644
+ // 0-ahead security guard as shipMain (applyBaseOverride is the shared gate).
645
+ const baseRefRaw = arg('--base', args);
646
+ const dbOnly = args.includes('--db-only') || args.includes('--allow-empty') || Boolean(baseRefRaw); // task 391 — honor --db-only on a regrade too
647
+ const verified = args.includes('--verified'); // task 1053 (F) — see gate below
648
+
649
+ if (baseRefRaw) {
650
+ const res = applyBaseOverride(baseRefRaw);
651
+ if (!res.ok) {
652
+ console.error(`Regrade aborted: ${res.reason}. Omit --base and re-grade normally.`);
653
+ process.exit(2);
654
+ }
655
+ console.log(`--base: re-grading ${res.sha.slice(0, 9)}...HEAD (work already on main).`);
656
+ }
657
+
658
+ // ---- Eligibility: completed + (owner | archon) ----
659
+ const me = noteServerReward(await (await cliClient()).me.getMe());
660
+ if (!me.ok) {
661
+ console.error('failed to load profile:', me.data);
662
+ process.exit(1);
663
+ }
664
+ const builder = me.data.builder || {};
665
+ // ?include=grade,last_claim — the claim-holder lookup for eligibility (#678)
666
+ // + the stored grade row for the delta-aware prior-round payload (task 1002662).
667
+ const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'grade,last_claim' } });
668
+ if (!tRes.ok) {
669
+ if (tRes.status === 404) {
670
+ console.error(`Task #${taskId} not found.`);
671
+ } else {
672
+ console.error(`failed to load task #${taskId} (${tRes.status}):`, tRes.data);
673
+ }
674
+ process.exit(1);
675
+ }
676
+ const task = tRes.data.task;
677
+ const lastClaim = tRes.data.last_claim || null;
678
+ // pushVia() decides whether /merge-mode is even runnable here (task 1003221).
679
+ const elig = evaluateRegradeEligibility({ task, lastClaim, builder, pushMode: pushVia() });
680
+ if (!elig.ok) {
681
+ console.error(elig.reason);
682
+ process.exit(1);
683
+ }
684
+ // task 1053 (F): the resume/regrade path promotes completed→confirmed via the
685
+ // grade (applyGrade), which the server-side /confirm verify guard does NOT
686
+ // cover. Gate it here so a verification task can't be confirmed by simply
687
+ // re-running without attesting the verify passed (closing the bypass).
688
+ if (task.kind === 'verify' && !verified) {
689
+ console.error('');
690
+ console.error(`Task #${taskId} is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
691
+ console.error(`Run the verification, then re-run with --verified:`);
692
+ console.error(` node scripts/gds/ship.js ${taskId} --verified`);
693
+ process.exit(1);
694
+ }
695
+ console.log(`Re-grading task #${taskId}: ${task.title}`);
696
+ console.log(` eligibility: ${elig.reason} (${elig.via})`);
697
+
698
+ // A grade-failed ship never reached the merge/broadcast step, so a passing
699
+ // re-grade is the FIRST time this task actually ships. Fall back to the
700
+ // task's stored value_summary so the merge commit + Discord #ship-news post
701
+ // carry meaningful text even when --summary is omitted.
702
+ const summary = summaryArg || task.value_summary || '';
703
+
704
+ // The grader helpers want a claim-like object for touches[] (preflight's
705
+ // working-tree-drift check) and head_sha (baseline fallback). The claim is
706
+ // gone, so synthesize one from the task row + the last claim's head_sha.
707
+ const claimLike = { touches: task.touches || [], head_sha: lastClaim?.head_sha ?? null, task_id: taskId, title: task.title };
708
+
709
+ // ---- Preflight: work committed + ship/grade tooling current ----
710
+ // No scanner/smoke re-run — --regrade re-runs ONLY the grade step. But the
711
+ // grader judges COMMITTED history, so uncommitted fixes on touches[] files
712
+ // wouldn't be graded — refuse that (it would grade a stale diff). Also refuse
713
+ // if the local ship/grade tooling is behind main (a stale grader silently
714
+ // fails the same way — the #662 lesson). --force overrides the freshness leg.
715
+ console.log('Preflight (working tree + branch freshness)...');
716
+ const pre = preflight(claimLike, { force });
717
+ if (!pre.ok) {
718
+ console.error('');
719
+ console.error(`Re-grade aborted: ${pre.reason}. Task stays at completed.`);
720
+ process.exit(1);
721
+ }
722
+
723
+ // ---- Empty-diff guard (the grader reads committed history) ----
724
+ const graderBaseline = computeGraderBaseline(claimLike);
725
+ const committedFiles = computeChangedFiles(graderBaseline);
726
+ if (committedFiles.length === 0) {
727
+ console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
728
+ console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
729
+ process.exit(1);
730
+ }
731
+
732
+ // ---- Delta-aware prior-round context (task 1002662) ----
733
+ // The prior grade's findings + the builder's response + the interim fix diff
734
+ // ride to the panel so an already-answered finding can't re-veto on
735
+ // re-wording alone. The interim range comes from the prior grade's
736
+ // signals.graded_head_sha (recorded by runSubagentGrader since this task);
737
+ // older grade rows lack it — the findings still ride, just without the diff.
738
+ const storedGradeRow = tRes.data.grade ?? null;
739
+ let interimCommits = '';
740
+ let interimDiffStat = '';
741
+ const priorSha = storedGradeRow?.signals?.graded_head_sha;
742
+ // The sha comes from a DB JSONB blob — validate the shape before shelling out.
743
+ if (typeof priorSha === 'string' && /^[0-9a-f]{7,40}$/i.test(priorSha) && gitOk(`git rev-parse --verify --quiet ${priorSha}`)) {
744
+ interimCommits = gitOk(`git log --oneline ${priorSha}..HEAD`) || '';
745
+ interimDiffStat = gitOk(`git diff --stat ${priorSha}..HEAD`) || '';
746
+ }
747
+ const priorRound = buildPriorRoundContext({
748
+ storedGrade: storedGradeRow,
749
+ responseText: responseArg,
750
+ interimCommits,
751
+ interimDiffStat,
752
+ });
753
+ if (priorRound) {
754
+ console.log(` prior round context: ${priorRound.audit.prior_findings_count} finding(s) from the prior grade${priorRound.audit.response_present ? ' + builder response' : ''}${priorRound.audit.interim_present ? ' + interim fix diff' : ''} → round ${priorRound.audit.round}`);
755
+ }
756
+
757
+ // ---- Grade step (the only step) ----
758
+ console.log('');
759
+ console.log('Running subagent quality grader...');
760
+ const gradePost = await gradeAndRecord({
761
+ taskId,
762
+ task,
763
+ summary,
764
+ baseline: graderBaseline,
765
+ committedFiles,
766
+ priorRound,
767
+ });
768
+ if (!gradePost.ok) {
769
+ console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
770
+ console.error('Task stays at completed. Fix the issue and re-run --regrade (unlimited, no attempt cap).');
771
+ console.error('(POST /confirm is a last resort — it is permission-gated on task.confirm.grade_bypass.)');
772
+ process.exit(1);
773
+ }
774
+
775
+ const g = gradePost.data.grade;
776
+ const postGradeStatus = gradePost.data.taskStatus;
777
+ printGradeOutcome(g, taskId);
778
+ printCreditsAndAchievements({
779
+ postGradeStatus,
780
+ credits: gradePost.data.creditsAwarded,
781
+ creditsRaw: gradePost.data.creditsRaw ?? null,
782
+ creditsMultiplier: gradePost.data.creditsMultiplier ?? null,
783
+ ideaBonusInfo: gradePost.data.ideaBonus ?? null,
784
+ justUnlocked: gradePost.data.just_unlocked || [],
785
+ });
786
+
787
+ // On a pass the task is now confirmed — run the SAME confirmed→shipped tail as a
788
+ // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
789
+ await finishShip({
790
+ taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
791
+ postGradeStatus,
792
+ doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
793
+ // task 1270: completion card for the re-grade path too.
794
+ card: {
795
+ title: task.title,
796
+ valueSummary: summary,
797
+ notes: '',
798
+ files: committedFiles,
799
+ baseline: graderBaseline,
800
+ creditsAwarded: gradePost.data.creditsAwarded,
801
+ grade: { passed: g.passed, issues: g.issues || [], panel: Array.isArray(g.workers) ? g.workers.length : null },
802
+ },
803
+ });
804
+ }
805
+
806
+ // Pure helpers are exported for unit tests (tests/session_records.mjs). The
807
+ // orchestration only runs when ship.js is invoked directly — so `require()`ing
808
+ // this module (a test) does NOT kick off a ship. (uploadSessionOnShip is not
809
+ // exported: it does I/O + network and is covered by the live smoke at ship.)
810
+
811
+ module.exports = {
812
+ regradeMain,
813
+ shipMain,
814
+ };