@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,100 @@
1
+ // scripts/gds/ship-git.js — the shell/git/argv runners and the injection-safe merge-argv builders.
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 560-638 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 { execSync, execFileSync } = require('node:child_process');
12
+
13
+ function gitOk(cmd, opts = {}) {
14
+ try {
15
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
16
+ } catch (_) {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function shellOk(cmd, opts = {}) {
22
+ try {
23
+ execSync(cmd, { stdio: 'inherit', ...opts });
24
+ return true;
25
+ } catch (_) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // argvOk / argvOut — the two no-shell exec primitives behind the git/gh wrappers.
31
+ // Run `<cmd> <args...>` so each arg reaches the program as a literal argv element
32
+ // (never interpolated into a shell string) — the discipline that closes the
33
+ // autoMerge injection (#681), since branch names + DB-sourced value_summary flow
34
+ // through here. `shell: false` is forced AFTER the opts spread so a caller can
35
+ // never pass `{ shell: true }` and silently re-enable shell interpretation — the
36
+ // no-shell guarantee is the whole point and must not be overridable.
37
+ // argvOk — exit status only → boolean (gitArgvOk / ghArgvOk). stdio 'inherit'.
38
+ // argvOut — capture trimmed stdout → string|null (ghJson / fetchDeployedVersion).
39
+ // Default stdio captures BOTH stdout and stderr; a caller that must
40
+ // DISCARD stderr passes its own `stdio: ['ignore','pipe','ignore']`.
41
+ function argvOk(cmd, args, opts = {}) {
42
+ try {
43
+ execFileSync(cmd, args, { stdio: 'inherit', ...opts, shell: false });
44
+ return true;
45
+ } catch (_) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function argvOut(cmd, args, opts = {}) {
51
+ try {
52
+ return execFileSync(cmd, args, {
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ ...opts,
56
+ shell: false,
57
+ }).trim();
58
+ } catch (_) {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // gitArgvOk — the git boolean runner. The boolean/`stdio:'inherit'` contract
64
+ // matches shellOk so callers can swap one for the other; see buildMergeArgv + the
65
+ // #681 regression test.
66
+ function gitArgvOk(args, opts = {}) {
67
+ return argvOk('git', args, opts);
68
+ }
69
+
70
+ // sanitizeGitHubRefs — rewrite "#NNN" → "task NNN" in any text that lands on a
71
+ // GitHub-rendered surface that can't carry a markdown link (commit messages,
72
+ // PR titles). GitHub autolinks every bare "#NNN" there to its OWN PR/issue
73
+ // number space, which our GDS task ids long since diverged from — so a value
74
+ // summary like "#914 player straw hat" rendered a dead link to issues/914 on
75
+ // every merge commit (task 916). Pure + exported for the regression test.
76
+ function sanitizeGitHubRefs(text) {
77
+ return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
78
+ }
79
+
80
+ // buildMergeArgv — construct the argv for the auto-merge commit. Pure +
81
+ // exported so the regression test can assert the untrusted value_summary lands
82
+ // as a single literal argv element (never spliced into a shell string). The
83
+ // merge message is `Merge <branch>: <summary>` with task refs de-#-ed (commit
84
+ // messages can't carry markdown links, so a bare "#NNN" would autolink to a
85
+ // dead GitHub issue — task 916); transport stays argv, never a shell string.
86
+ function buildMergeArgv(branch, valueSummary) {
87
+ const summary = sanitizeGitHubRefs(valueSummary) || `(no value summary)`;
88
+ const mergeMsg = `Merge ${branch}: ${summary}`;
89
+ return ['merge', '--no-ff', branch, '-m', mergeMsg];
90
+ }
91
+
92
+ module.exports = {
93
+ argvOk,
94
+ argvOut,
95
+ buildMergeArgv,
96
+ gitArgvOk,
97
+ gitOk,
98
+ sanitizeGitHubRefs,
99
+ shellOk,
100
+ };
@@ -0,0 +1,376 @@
1
+ // scripts/gds/ship-grade-steps.js — the grade step shared by shipMain and regradeMain: payload, reuse, scoring, printing.
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 2580-2927 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
+ const { getServerReward } = require('./ship-state.js');
11
+
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const { cliClient, rewardPolicy } = require('./cli-lib');
15
+ const { gitOk } = require('./ship-git.js');
16
+ const { resolveInstanceBase } = require('./ship-deploy-target.js');
17
+ const { computeRawDiffHash, reconstructPanelIssues, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
18
+
19
+ // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
20
+ //
21
+ // #678: the grade step is factored out of shipMain so the `--regrade` recovery
22
+ // path can reuse it verbatim instead of replicating ~50 lines (the trap that
23
+ // learning #43 warned about — "runSubagentGrader in ship.js is NOT exported, so
24
+ // replicate its ~10 lines"). These helpers are behavior-preserving lifts of the
25
+ // inline blocks shipMain used before; both paths now share one implementation.
26
+
27
+ // Map a grader result into the POST /tasks/:id/grade request body. Forwards the
28
+ // advisory sub-scores (economy/security/efficiency) only when present so the
29
+ // server's validateAndScore re-records them (back-compat: omitted when null).
30
+ function buildGradePayload(gradeResult, committedFiles = null) {
31
+ return {
32
+ // touches[] cleanup [3/8] (task 878): pass the real committed changed-files
33
+ // so the server keys the shipper badges off the diff, not touches[].
34
+ ...(Array.isArray(committedFiles) && committedFiles.length > 0
35
+ ? { committed_files: committedFiles }
36
+ : {}),
37
+ subagent_output: {
38
+ functional_fidelity: gradeResult.rubric_scores?.functional_fidelity,
39
+ code_quality: gradeResult.rubric_scores?.code_quality,
40
+ ...(gradeResult.rubric_scores?.economy != null
41
+ ? { economy: gradeResult.rubric_scores.economy }
42
+ : {}),
43
+ ...(gradeResult.rubric_scores?.security_score != null
44
+ ? { security_score: gradeResult.rubric_scores.security_score }
45
+ : {}),
46
+ ...(gradeResult.rubric_scores?.efficiency_score != null
47
+ ? { efficiency_score: gradeResult.rubric_scores.efficiency_score }
48
+ : {}),
49
+ notes_md: gradeResult.notes_md,
50
+ confidence: gradeResult.signals?.confidence ?? null,
51
+ issues: gradeResult.issues,
52
+ },
53
+ signals: gradeResult.signals,
54
+ };
55
+ }
56
+
57
+ // Log the grader panel's own API spend (#486 / ADR 0028 path 1). Best-effort +
58
+ // idempotent on (source, source_ref): a cost-log failure must never block a
59
+ // ship/re-grade, and re-grading at the same HEAD is a no-op.
60
+ async function logGraderPanelCost(taskId, gradeResult) {
61
+ const panelCostUsd = Number(gradeResult.panel_cost_usd) || 0;
62
+ if (panelCostUsd <= 0) return;
63
+ const headSha = gitOk('git rev-parse --short HEAD') || 'nohead';
64
+ const costPost = await (await cliClient()).cost.postCost({
65
+ body: {
66
+ task_id: Number(taskId),
67
+ amount_usd: panelCostUsd,
68
+ category: 'api',
69
+ source: 'grader',
70
+ source_ref: `grade-${taskId}-${headSha}`,
71
+ description: `grader panel (${gradeResult.grader_kind || 'subagent'}) on #${taskId}`,
72
+ },
73
+ });
74
+ // An idempotent conflict returns HTTP 200 {skipped:true} (cost.js), so
75
+ // costPost.ok is TRUE on a skip — check `skipped` first.
76
+ if (costPost.data && costPost.data.skipped) {
77
+ console.log(` grader cost already logged for ${headSha} (idempotent skip)`);
78
+ } else if (costPost.ok) {
79
+ console.log(` grader cost logged: $${panelCostUsd.toFixed(4)} → cost_log (category=api, source=grader)`);
80
+ } else {
81
+ console.log(` grader cost log skipped (non-fatal): HTTP ${costPost.status}`);
82
+ }
83
+ }
84
+
85
+ // task 1002662: assemble the delta-aware prior-round context for a --regrade.
86
+ // Pure (no git/network) so the payload shape is unit-tested. Returns null when
87
+ // there is no prior grade worth being delta-aware of; otherwise:
88
+ // forPanel — { round, findings_md, response_md, interim_md } for the worker
89
+ // prompts (fenced as untrusted by the shared scaffold);
90
+ // audit — the compact regrade block recorded into the new grade's signals
91
+ // so grade_attempts rounds read as remediation history (the
92
+ // task-1002661 reader consumes this).
93
+ const PRIOR_ROUND_TEXT_CAP = 4000;
94
+ function buildPriorRoundContext({ storedGrade, responseText = '', interimCommits = '', interimDiffStat = '' }) {
95
+ if (!storedGrade || typeof storedGrade !== 'object') return null;
96
+ const issues = Array.isArray(storedGrade.issues) ? storedGrade.issues : [];
97
+ const notes = typeof storedGrade.notes_md === 'string' ? storedGrade.notes_md.trim() : '';
98
+ if (issues.length === 0 && !notes) return null;
99
+ const verdictLine = `Prior verdict: ${storedGrade.passed ? 'PASS' : 'FAIL'} · score ${storedGrade.score ?? '?'}/10 · ${issues.length} finding(s)`;
100
+ const findingLines = issues.map((it) => `- [${it.severity || '?'}] ${it.worker || 'panel'} · ${it.file || '?'} — ${it.summary || ''}`);
101
+ const findings_md = [
102
+ verdictLine,
103
+ '',
104
+ ...(findingLines.length ? findingLines : ['(no per-finding rows — see the prior notes below)']),
105
+ ...(notes ? ['', 'Prior panel notes:', notes.slice(0, PRIOR_ROUND_TEXT_CAP)] : []),
106
+ ].join('\n');
107
+ const interimParts = [];
108
+ if (interimCommits) interimParts.push('Interim commits since the prior grade:', String(interimCommits).slice(0, PRIOR_ROUND_TEXT_CAP));
109
+ if (interimDiffStat) interimParts.push('', 'Interim diffstat:', String(interimDiffStat).slice(0, PRIOR_ROUND_TEXT_CAP));
110
+ const response = String(responseText || '').trim();
111
+ // Round numbering is self-consistent across regrades: the first grade is
112
+ // round 1; each regrade reads the prior signals.regrade.round when present.
113
+ const round = (Number(storedGrade.signals?.regrade?.round) || 1) + 1;
114
+ return {
115
+ forPanel: {
116
+ round,
117
+ findings_md,
118
+ response_md: response ? response.slice(0, PRIOR_ROUND_TEXT_CAP) : null,
119
+ interim_md: interimParts.length ? interimParts.join('\n') : null,
120
+ },
121
+ audit: {
122
+ round,
123
+ prior_passed: storedGrade.passed === true,
124
+ prior_score: storedGrade.score ?? null,
125
+ prior_findings_count: issues.length,
126
+ response_present: Boolean(response),
127
+ interim_present: Boolean(interimCommits || interimDiffStat),
128
+ },
129
+ };
130
+ }
131
+
132
+ // Run the subagent grader against the committed diff, POST the result to the
133
+ // rank-open /tasks/:id/grade endpoint, and log the panel cost on success.
134
+ // Returns the raw apiCall response so the caller can branch on .ok / read the
135
+ // grade. Shared by the ship path and the --regrade path.
136
+ //
137
+ // V4.R08: before spawning the adversarial panel we compute the raw diff hash
138
+ // and check whether a stored PASS grade exists for the same hash. On a match
139
+ // we re-POST the stored grade (marking it reused in signals) and skip the
140
+ // panel entirely — cheap re-ships with an unchanged diff stay fast.
141
+ // task 1002660: the COMPUTE half — the reuse lookup + the panel run. No server
142
+ // writes, so shipMain can start it before the smoke gate and overlap the
143
+ // multi-minute panel with smoke + the claim resolve. Returns
144
+ // { kind: 'reuse', currentHash, reusedResult } | { kind: 'fresh', currentHash, gradeResult }.
145
+ async function computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound = null }) {
146
+ // Compute the fingerprint of the raw (untruncated) diff so we can compare
147
+ // against any stored grade, independent of the display-truncation cap.
148
+ const currentHash = computeRawDiffHash(baseline);
149
+
150
+ if (currentHash) {
151
+ const storedGradeRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'grade' } });
152
+ const storedGrade = storedGradeRes.ok ? (storedGradeRes.data.grade ?? null) : null;
153
+
154
+ if (shouldReuseGrade(storedGrade, currentHash)) {
155
+ console.log(' Grade reuse: stored PASS grade matches current diff hash — skipping panel.');
156
+ // Reuse the stored result with a reused marker so the audit trail is
157
+ // complete. We keep the original grader_kind and inject reuse metadata
158
+ // into signals so a bare table scan can spot reused grades.
159
+ const reusedSignals = {
160
+ ...(storedGrade.signals || {}),
161
+ reused_from_diff_hash: storedGrade.diff_hash,
162
+ reused_at: new Date().toISOString(),
163
+ };
164
+ const reusedResult = {
165
+ passed: storedGrade.passed,
166
+ score: storedGrade.score,
167
+ threshold: storedGrade.threshold,
168
+ grader_kind: storedGrade.grader_kind,
169
+ grader_version: storedGrade.grader_version,
170
+ rubric_scores: storedGrade.rubric_scores,
171
+ notes_md: storedGrade.notes_md,
172
+ signals: reusedSignals,
173
+ // task 1002660: carry the original panel's issues (rebuilt from
174
+ // signals.workers[].issues — the row stores them nowhere else) so the
175
+ // reused PASS stands on the same substance/evidence it passed with.
176
+ issues: reconstructPanelIssues(storedGrade.signals),
177
+ };
178
+ return { kind: 'reuse', currentHash, reusedResult };
179
+ }
180
+ }
181
+
182
+ const gradeResult = await runSubagentGrader({
183
+ task,
184
+ valueSummary: summary,
185
+ baseline,
186
+ changedFiles: committedFiles,
187
+ priorRound: priorRound?.forPanel ?? null,
188
+ });
189
+ // task 1002662: record the regrade audit block on the new grade's signals so
190
+ // this round is readable as remediation history (grade_attempts keeps one row
191
+ // per round; the latest grade's signals.regrade names what it responded to).
192
+ if (priorRound?.audit) {
193
+ gradeResult.signals = { ...(gradeResult.signals || {}), regrade: priorRound.audit };
194
+ }
195
+ return { kind: 'fresh', currentHash, gradeResult };
196
+ }
197
+
198
+ // The RECORD half: POST the computed grade + log the panel cost. `precomputed`
199
+ // (a promise from computeGradeResult, task 1002660) lets the ship path hand in
200
+ // the overlapped computation; the --regrade path passes nothing and computes
201
+ // inline — behavior identical to the pre-split gradeAndRecord either way.
202
+ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null, precomputed = null }) {
203
+ const computed = await (precomputed
204
+ || computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound }));
205
+
206
+ if (computed.kind === 'reuse') {
207
+ const payload = { ...buildGradePayload(computed.reusedResult, committedFiles), diff_hash: computed.currentHash };
208
+ return (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
209
+ // No logGraderPanelCost — the panel didn't run, there's no cost to log.
210
+ }
211
+
212
+ const { gradeResult } = computed;
213
+ const payload = { ...buildGradePayload(gradeResult, committedFiles), diff_hash: computed.currentHash ?? undefined };
214
+ const gradePost = await (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
215
+ // Log the panel's cost once the grade is recorded — independent of the
216
+ // pass/fail VERDICT (the panel ran, and cost money, either way), but only
217
+ // when the POST itself succeeded (a failed POST means nothing was recorded).
218
+ if (gradePost.ok) await logGraderPanelCost(taskId, gradeResult);
219
+ return gradePost;
220
+ }
221
+
222
+ // Build the grade verdict display block as a string (per-axis scores + notes +
223
+ // issues + next-step hints). Pure + exported so the #943 grader-unavailable vs
224
+ // FAIL branch is unit-tested without capturing stdout. printGradeOutcome just
225
+ // prints what this returns.
226
+ function formatGradeOutcome(g, taskId) {
227
+ const out = [];
228
+ // #943: a TOTAL grader outage (every gating worker failed to spawn / the
229
+ // grader was bypassed pre-spawn) is NOT a quality rejection — it's "the grader
230
+ // could not run". Present it distinctly so a worker-infra failure never reaches
231
+ // the builder as a misleading "FAIL · 0/10 — fix the issues" (the session-1659
232
+ // false-FAIL). There are no issues to fix; the path forward is re-grade when the
233
+ // grader is reachable, or manual-confirm.
234
+ if (g.signals?.panel_outcome === 'unavailable') {
235
+ const reason = g.signals?.grader_unavailable_reason;
236
+ out.push('');
237
+ out.push(`Subagent grade: GRADER UNAVAILABLE${reason ? ` (${reason})` : ''} · no quality signal produced — NOT a quality rejection`);
238
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
239
+ out.push('');
240
+ out.push('The grader could not run (infra/outage/bypass) — there are no findings to fix. Task stays at completed.');
241
+ out.push('When the grader is reachable again, re-grade:');
242
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
243
+ out.push('Or land it now via manual-confirm:');
244
+ out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/gds/tasks/${taskId}/confirm`);
245
+ return out.join('\n');
246
+ }
247
+
248
+ // task 1002614 (map P-2): a protected-path refusal used to render as an
249
+ // ordinary quality FAIL — "fix the issues and --regrade" — although no rank of
250
+ // the builder's can land that diff. Present it as the authorization wall it is
251
+ // (the #943 grader-unavailable branch is the precedent for a distinct shape).
252
+ // Keyed off the guard name so it also reads grades from servers predating the
253
+ // authorization_refusal marker.
254
+ const guardTripped = g.signals?.server_guard?.tripped || [];
255
+ const permRefusal = g.signals?.server_guard?.authorization_refusal === true
256
+ || guardTripped.some((t) => t.guard === 'permission_path');
257
+ if (!g.passed && permRefusal) {
258
+ out.push('');
259
+ out.push('Subagent grade: REFUSED (authorization) · not a quality verdict — your work was not judged and rejected');
260
+ out.push('');
261
+ out.push('The committed diff touches PROTECTED surfaces above your rank, so the server');
262
+ out.push('refused to accept it — no re-attempt at your rank can land this diff as-is.');
263
+ if ((g.issues || []).length > 0) {
264
+ out.push('');
265
+ out.push('What was refused (file · rule):');
266
+ for (const it of g.issues.filter((i) => String(i.worker || '').includes('permission-path'))) {
267
+ out.push(` ${it.file || '?'} — ${it.summary || ''}`);
268
+ }
269
+ }
270
+ out.push('');
271
+ out.push('Ways forward:');
272
+ out.push(' - Split the protected files out of your diff and re-ship the rest, or');
273
+ out.push(' - hand this task to a builder at/above the floor the refusal names.');
274
+ out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
275
+ return out.join('\n');
276
+ }
277
+
278
+ const verdict = g.passed ? 'PASS' : 'FAIL';
279
+ out.push('');
280
+ out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
281
+ if (g.rubric_scores) {
282
+ const ff = g.rubric_scores.functional_fidelity;
283
+ const cq = g.rubric_scores.code_quality;
284
+ if (ff != null) out.push(` functional_fidelity: ${ff}/10`);
285
+ if (cq != null) out.push(` code_quality: ${cq}/10`);
286
+ }
287
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
288
+ if ((g.issues || []).length > 0) {
289
+ out.push('');
290
+ out.push(`Issues (${g.issues.length}):`);
291
+ for (const it of g.issues) {
292
+ const sev = String(it.severity || '?').padEnd(7);
293
+ out.push(` [${sev}] ${it.file || '?'} — ${it.summary || ''}`);
294
+ }
295
+ }
296
+ if (!g.passed) {
297
+ out.push('');
298
+ out.push('Grade failed — task stays at completed. Fix the issues above, commit, then re-grade:');
299
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
300
+ out.push('Last resort (only if the grade was wrong):');
301
+ out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/gds/tasks/${taskId}/confirm`);
302
+ }
303
+ return out.join('\n');
304
+ }
305
+
306
+ // Print the grade verdict + per-axis scores + notes + issues. On a FAIL, print
307
+ // both the re-grade hint (the cheap default) and the Archon override curl (the
308
+ // last resort — see feedback_prefer_regrade_over_override). #943: a total grader
309
+ // outage prints as "GRADER UNAVAILABLE", not a 0/10 FAIL — see formatGradeOutcome.
310
+ function printGradeOutcome(g, taskId) {
311
+ console.log(formatGradeOutcome(g, taskId));
312
+ }
313
+
314
+ // Print the credits line + multiplier breakdown + idea bonus + unlocked
315
+ // achievements from a grade/confirm response.
316
+ function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked }) {
317
+ console.log('');
318
+ if (postGradeStatus === 'completed') {
319
+ console.log(`Credits awarded: ${credits} (none — task at completed, grade not passed)`);
320
+ } else if (postGradeStatus === 'confirmed') {
321
+ console.log(`Credits awarded: ${credits} (none yet — credits land when the task merges to main)`);
322
+ } else if (!rewardPolicy(getServerReward()).perTaskCredit) {
323
+ // cost-plus-only (ADR 0146): the per-task estimate is never paid, so this is
324
+ // 0 BY DESIGN. Left unexplained it reads as a broken payout — it sent a real
325
+ // session diagnosing the reward gate on 2026-07-26 (task 1002469).
326
+ console.log(`Credits awarded: ${credits} — expected: this instance pays cost-plus only.`);
327
+ console.log(" The task's estimate is a guide, never a payout. You're paid for this session's");
328
+ console.log(' real cost + 20%, booked separately from your token spend.');
329
+ } else {
330
+ console.log(`Credits awarded: ${credits}`);
331
+ }
332
+ // Write the credits flag so the NEXT Claude turn plays the credits-awarded
333
+ // sound via the Stop hook (task 582). Only when credits were actually awarded.
334
+ if (typeof credits === 'number' && credits > 0) {
335
+ const tmpDir = process.platform === 'win32'
336
+ ? (process.env.TEMP || process.env.TMP || os.tmpdir()) : '/tmp';
337
+ try { fs.writeFileSync(require('path').join(tmpDir, 'otb-credits'), ''); } catch (_) {}
338
+ }
339
+ if (creditsRaw != null && creditsMultiplier != null && Number(creditsMultiplier) !== 1.0) {
340
+ const mult = Number(creditsMultiplier).toFixed(2).replace(/0$/, '').replace(/\.$/, '.0');
341
+ console.log(` Kind multiplier: ${creditsRaw}c × ${mult}× → ${credits}c`);
342
+ }
343
+ if (ideaBonusInfo && ideaBonusInfo.ideaBonusAwarded > 0) {
344
+ console.log(
345
+ ` Idea-promotion bonus: +${ideaBonusInfo.ideaBonusAwarded}c paid to builder #${ideaBonusInfo.bonusBuilderId} (idea #${ideaBonusInfo.ideaId})`
346
+ );
347
+ }
348
+ for (const ach of justUnlocked || []) {
349
+ const bonus = ach.credit_bonus > 0 ? ` (+${ach.credit_bonus}c bonus)` : '';
350
+ console.log(`✨ Achievement unlocked: ${ach.name}${bonus}`);
351
+ }
352
+ }
353
+
354
+ // chooseBroadcastTargets — the ship-broadcast channel router (task 990) now lives
355
+ // in src/bongos/ship-broadcast.js (task 1045) so the server-mediated publish path
356
+ // shares the exact same security-sensitive routing. Imported at the top of this
357
+ // file; still re-exported below for tests/ship_broadcast_routing.mjs.
358
+
359
+ // task 1442: play the task-shipped "ship bell". ship.js runs as its own process
360
+ // (not inside a Claude turn), so it plays the sound directly rather than via the
361
+ // Stop hook. Called at the confirmed/shipped success milestone so it rings on
362
+ // the ci/async landing path too — there ship.js exits at 'confirmed' and the
363
+ // deploy finishes later server-side, which is exactly why the old post-deploy
364
+ // call site never fired (bug #1442). Resolve the builder's chosen variant from
365
+ // the local prefs file; fall back to 'ship'. Best-effort: playSound swallows all
366
+ // errors and is a silent no-op with no audio player, so this can never fail a ship.
367
+
368
+ module.exports = {
369
+ buildGradePayload,
370
+ buildPriorRoundContext,
371
+ computeGradeResult,
372
+ formatGradeOutcome,
373
+ gradeAndRecord,
374
+ printCreditsAndAchievements,
375
+ printGradeOutcome,
376
+ };