@bongos/core 1.21.103 → 1.21.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.103",
6
- "core_contract": "1.21.103",
7
- "source_commit": "56f5f9df610b5d9079d28702989bf24a9a462db9",
5
+ "core_version": "1.21.104",
6
+ "core_contract": "1.21.104",
7
+ "source_commit": "cfc8c3651ea3f442385e6d0ba405c6a578b4d83a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T13:27:17.718Z",
9
+ "built_at": "2026-10-09T13:49:17.283Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 592,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3020,
14
+ "functional_verbatim": 3021,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3641,
20
- "tree_sha256": "3e52cfca3b8385e564d956ceaae717fbedb093d4db368a3c960d3f6db8c6139a",
19
+ "file_count": 3642,
20
+ "tree_sha256": "b23d12f504071337f2882e99100b69132448daa2e65560c851411b6a667c5f67",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2872,7 +2872,7 @@
2872
2872
  {
2873
2873
  "path": "docs/module-api-changelog.md",
2874
2874
  "mode": "0000644",
2875
- "sha256": "53e81515be4483fdb9a4d361335cbe9bac0efb82bd28946cf6b70d0ca28b0b3e"
2875
+ "sha256": "6e7ad3fddfaca1cfb1dfe79cbdb4a5588007b30eedd458df7212a971da96e324"
2876
2876
  },
2877
2877
  {
2878
2878
  "path": "docs/modules-contract.md",
@@ -10137,12 +10137,12 @@
10137
10137
  {
10138
10138
  "path": "package-lock.json",
10139
10139
  "mode": "0000644",
10140
- "sha256": "bc23786ce9a171a96d049bc9568d8a73e513c0aa66783cedae0e51d4b16b6d93"
10140
+ "sha256": "7f4d097ca5d827a3fad698b3e3fbbedc38f7469e29b565cfed34489f4eaab008"
10141
10141
  },
10142
10142
  {
10143
10143
  "path": "package.json",
10144
10144
  "mode": "0000644",
10145
- "sha256": "923e849fd1ededbb5563d4c81dc9013c308a99a53f9d8595724fa8b35e831914"
10145
+ "sha256": "a0fb2f62847ad58e78ec122f44bb7e97281f124bdfd0324c767a50bfe26b5a9e"
10146
10146
  },
10147
10147
  {
10148
10148
  "path": "public-docs/index.html",
@@ -10162,7 +10162,7 @@
10162
10162
  {
10163
10163
  "path": "release-notes.json",
10164
10164
  "mode": "0000644",
10165
- "sha256": "5f152e713ca6f702119fe887e076a370e72ad380cd3c880ef56683202f3b573f"
10165
+ "sha256": "f65d5a86053502037145a87a3e996ce4ff32c84651d4a8f8afe62c486172ef2d"
10166
10166
  },
10167
10167
  {
10168
10168
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11642,7 +11642,7 @@
11642
11642
  {
11643
11643
  "path": "scripts/gds/ship-flow.js",
11644
11644
  "mode": "0000644",
11645
- "sha256": "d114ae690d86270db76770fc3531a5530d1ab64a031a2528368864f4b330ac56"
11645
+ "sha256": "ad768320095b1d6693e6da5cee1f8e857ac69cbbfbea0a64dfeaa7092c432de2"
11646
11646
  },
11647
11647
  {
11648
11648
  "path": "scripts/gds/ship-git.js",
@@ -11722,7 +11722,7 @@
11722
11722
  {
11723
11723
  "path": "scripts/gds/ship-visual.js",
11724
11724
  "mode": "0000644",
11725
- "sha256": "21ce15f1e49388bb3f8a46b9cfd8b05b23b77efbe93228ca04d9b13740e6d901"
11725
+ "sha256": "a97c5628609b622ad8972e93af13ff32a85138c350756f4895bea24d469bc2fc"
11726
11726
  },
11727
11727
  {
11728
11728
  "path": "scripts/gds/ship.js",
@@ -12437,7 +12437,7 @@
12437
12437
  {
12438
12438
  "path": "src/module-api.js",
12439
12439
  "mode": "0000644",
12440
- "sha256": "0228b28afc1fe4d1cd2a3e59ceeb9411a5ac0477bbdf95baf8a8de2100c2863a"
12440
+ "sha256": "a00e2f13c266f639d3052f84844f2f38634759e57cfd81830667a9bf1e3e7be7"
12441
12441
  },
12442
12442
  {
12443
12443
  "path": "src/module-loader/catalog.js",
@@ -17444,6 +17444,11 @@
17444
17444
  "mode": "0000644",
17445
17445
  "sha256": "75aa59accad12d197202457ed36281e5d3eca1040762a5045972c4b10cf09006"
17446
17446
  },
17447
+ {
17448
+ "path": "tests/ship_visual_regrade.mjs",
17449
+ "mode": "0000644",
17450
+ "sha256": "4e469f6f5801032dff196a57a84eeb3603e7a53760144a7ed4d582699c9747f9"
17451
+ },
17447
17452
  {
17448
17453
  "path": "tests/ship_zero_commit.mjs",
17449
17454
  "mode": "0000644",
@@ -3043,5 +3043,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3043
3043
  landed since 1.21.101 with no explicit bump. run 37935415352. (task 1002620)
3044
3044
  1.21.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3045
3045
  landed since 1.21.102 with no explicit bump. run 37936846665. (task 1002620)
3046
+ 1.21.104 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3047
+ landed since 1.21.103 with no explicit bump. run 37939497390. (task 1002620)
3046
3048
  ---------------------------------------------------------------------------
3047
3049
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.103",
3
+ "version": "1.21.104",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.103",
9
+ "version": "1.21.104",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.103",
3
+ "version": "1.21.104",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9464,5 +9464,11 @@
9464
9464
  "id": "1004684",
9465
9465
  "text": "New house rule, Slop Bongo: when an AI realises it was wrong about something, it now says 'Bongo Slop Detected, Filing Bug' in chat and files a bug against the doc or message that misled it, so the same mistake stops repeating"
9466
9466
  }
9467
+ ],
9468
+ "1.21.104": [
9469
+ {
9470
+ "id": "1004665",
9471
+ "text": "When a builder re-runs a review and attaches a screenshot, the screenshot now actually gets attached to the task. Before, it was quietly thrown away, so reviewers asking for a picture never saw one unless someone added it by h"
9472
+ }
9467
9473
  ]
9468
9474
  }
@@ -139,7 +139,7 @@ async function shipMain() {
139
139
  const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
140
140
  if (action.action === 'resume-grade') {
141
141
  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).`);
142
- return regradeMain();
142
+ return regradeMain({ visual });
143
143
  }
144
144
  if (action.action === 'resume-merge') {
145
145
  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).`);
@@ -822,12 +822,12 @@ async function persistRegradeSummary({ taskId, summaryArg, task }) {
822
822
  // promotes completed→confirmed + awards credits on pass, idempotent)
823
823
  // - on pass: the normal confirmed→shipped merge + sync tail runs.
824
824
 
825
- async function regradeMain() {
825
+ async function regradeMain(opts = {}) {
826
826
  await requireSession();
827
827
  const args = process.argv.slice(2);
828
828
  const taskId = Number(args[0]);
829
829
  if (!Number.isFinite(taskId)) {
830
- 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>]');
830
+ console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--visual <image>] [--visual-alt "caption"] [--no-merge] [--force] [--verified] [--base <ref>]');
831
831
  process.exit(2);
832
832
  }
833
833
  // task 1003702: refuse --notes rather than swallowing it. The reasoning — why
@@ -840,6 +840,16 @@ async function regradeMain() {
840
840
  for (const line of regradeNotesRefusal(taskId)) console.error(line);
841
841
  process.exit(2);
842
842
  }
843
+ // task 1004665: --visual used to be screened by shipMain and then dropped here
844
+ // without a word — this path returned before shipMain's upload, so visual_url
845
+ // stayed null (task 1004351). Screen it at the same early position as the
846
+ // --notes refusal (bad input costs no grade spend), and attach it on every
847
+ // path below that goes on to grade or land. shipMain's resume-grade dispatch
848
+ // hands over the visual it already screened rather than reading the file twice.
849
+ const visual = opts.visual !== undefined ? opts.visual : shipVisual.fromArgs(args, { regrade: true });
850
+ const attachVisual = async () => (visual
851
+ ? shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine })
852
+ : false);
843
853
  const regradeProse = resolveProse(args, ['summary', 'response']);
844
854
  const summaryArg = regradeProse.summary;
845
855
  // task 1002662: the builder's response to the prior round's findings — how
@@ -908,6 +918,7 @@ async function regradeMain() {
908
918
  // only re-publishes the branch (the recovery for a publish that failed).
909
919
  if (tweakHold.isHeldForArtist(task, tRes.data.grade ? tRes.data.grade.passed : null)) {
910
920
  console.log(`Task #${taskId} already passed its grade and is waiting for the artist: re-publishing its branch, not re-grading.`);
921
+ await attachVisual();
911
922
  await finishShip({
912
923
  taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
913
924
  card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
@@ -989,6 +1000,7 @@ async function regradeMain() {
989
1000
  console.log('');
990
1001
  console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
991
1002
  console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
1003
+ await attachVisual();
992
1004
  // A kind='verify' task cannot reach here without --verified: the gate above
993
1005
  // exits first, so the server's verify_attestation_required can't fire below.
994
1006
  const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
@@ -1060,6 +1072,9 @@ async function regradeMain() {
1060
1072
  // the panel runs afterwards. The row, the grader prompt, the merge commit and
1061
1073
  // the PR then all carry the same sentence.
1062
1074
  await persistRegradeSummary({ taskId, summaryArg, task });
1075
+ // task 1004665: the picture goes up at the same point and for the same reason
1076
+ // as the summary — after every guard that can still refuse, before the panel.
1077
+ await attachVisual();
1063
1078
 
1064
1079
  // ---- Delta-aware prior-round context (task 1002662) ----
1065
1080
  // The prior grade's findings + the builder's response + the interim fix diff
@@ -28,17 +28,24 @@ const taskVisuals = require('../../modules/lifecycle/task-visuals');
28
28
  // EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
29
29
  // costs the builder a retyped command, not a claim. Pure enough to reason about;
30
30
  // the exit is the deliberate CLI edge.
31
- function fromArgs(args) {
31
+ //
32
+ // `regrade: true` is the `ship.js <id> --regrade` caller (task 1004665). There is
33
+ // no claim left to resolve on that path, so the wording names what IS true there:
34
+ // the task stays at completed, and the picture goes up before the grade runs.
35
+ function fromArgs(args, { regrade = false } = {}) {
32
36
  const file = arg('--visual', args) || '';
33
37
  if (!file) return null;
34
38
  const read = taskVisuals.readImageFile(file);
35
39
  if (!read.ok) {
36
- console.error(`Ship aborted: --visual ${file} — ${read.message}`);
37
- console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
40
+ console.error(`${regrade ? 'Re-grade' : 'Ship'} aborted: --visual ${file} — ${read.message}`);
41
+ console.error(regrade
42
+ ? 'Fix the path (or drop --visual: a visual is always optional). Nothing was graded; the task stays at completed.'
43
+ : 'Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
38
44
  process.exit(2);
39
45
  }
40
46
  const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
41
- console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
47
+ const when = regrade ? 'uploaded before the re-grade runs' : 'uploaded after the claim resolves';
48
+ console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — ${when}.`);
42
49
  return visual;
43
50
  }
44
51
 
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.103'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.104'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,167 @@
1
+ // tests/ship_visual_regrade.mjs — `ship.js <id> --regrade --visual <image>`
2
+ // attaches the picture (task 1004665).
3
+ //
4
+ // It used to be dropped without a word: shipMain screened --visual and then
5
+ // returned into regradeMain() before its own upload, and regradeMain never
6
+ // looked at the flag, so visual_url stayed null (hit on task 1004351, where a
7
+ // grader blocker asked for screenshots). These cases pin the screening wording
8
+ // on the re-grade path, the best-effort upload, and the wiring — every path
9
+ // regradeMain takes to grade or land attaches the visual first.
10
+ //
11
+ // Run: node tests/ship_visual_regrade.mjs
12
+ import { strict as assert } from 'node:assert';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const shipVisual = require('../scripts/gds/ship-visual.js');
21
+
22
+ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'), Buffer.alloc(64)]);
23
+ const TMP = mkdtempSync(path.join(os.tmpdir(), 'ship-visual-regrade-'));
24
+ const good = path.join(TMP, 'after.png');
25
+ writeFileSync(good, PNG);
26
+ process.on('exit', () => rmSync(TMP, { recursive: true, force: true }));
27
+
28
+ // Capture console output and turn process.exit into a throw, so the CLI edge of
29
+ // fromArgs can be asserted without ending the test process.
30
+ function capture(fn) {
31
+ const out = []; const err = []; const warn = [];
32
+ const saved = { log: console.log, error: console.error, warn: console.warn, exit: process.exit };
33
+ console.log = (...a) => out.push(a.join(' '));
34
+ console.error = (...a) => err.push(a.join(' '));
35
+ console.warn = (...a) => warn.push(a.join(' '));
36
+ process.exit = (code) => { const e = new Error(`exit ${code}`); e.exitCode = code; throw e; };
37
+ let result; let exitCode = null;
38
+ try {
39
+ result = fn();
40
+ } catch (e) {
41
+ if (e.exitCode === undefined) throw e;
42
+ exitCode = e.exitCode;
43
+ } finally {
44
+ Object.assign(console, { log: saved.log, error: saved.error, warn: saved.warn });
45
+ process.exit = saved.exit;
46
+ }
47
+ return { result, exitCode, out: out.join('\n'), err: err.join('\n'), warn: warn.join('\n') };
48
+ }
49
+
50
+ // ---- screening ------------------------------------------------------------
51
+
52
+ test('no --visual means null on both paths, and nothing printed', () => {
53
+ for (const opts of [undefined, { regrade: true }]) {
54
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade'], opts));
55
+ assert.equal(r.result, null);
56
+ assert.equal(r.out + r.err, '');
57
+ }
58
+ });
59
+
60
+ test('on --regrade a good image is screened and says it goes up BEFORE the grade', () => {
61
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', good, '--visual-alt', 'the after'], { regrade: true }));
62
+ assert.equal(r.exitCode, null);
63
+ assert.equal(r.result.contentType, 'image/png');
64
+ assert.equal(r.result.alt, 'the after');
65
+ assert.match(r.out, /uploaded before the re-grade runs/);
66
+ assert.doesNotMatch(r.out, /claim resolves/, 'a re-grade has no claim left to resolve');
67
+ });
68
+
69
+ test('a normal ship keeps its own wording', () => {
70
+ const r = capture(() => shipVisual.fromArgs(['1', '--visual', good]));
71
+ assert.match(r.out, /uploaded after the claim resolves/);
72
+ });
73
+
74
+ test('on --regrade a bad path aborts BEFORE any grade, with re-grade wording', () => {
75
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', path.join(TMP, 'missing.png')], { regrade: true }));
76
+ assert.equal(r.exitCode, 2);
77
+ assert.match(r.err, /^Re-grade aborted: --visual/);
78
+ assert.match(r.err, /Nothing was graded; the task stays at completed/);
79
+ assert.doesNotMatch(r.err, /No claim resolved/);
80
+ });
81
+
82
+ // ---- upload ---------------------------------------------------------------
83
+
84
+ function fakeClient(response) {
85
+ const calls = [];
86
+ return {
87
+ calls,
88
+ tasks: { postTasksIdVisual: async (req) => { calls.push(req); return response; } },
89
+ };
90
+ }
91
+
92
+ test('upload posts the image to the task and reports it stuck', async () => {
93
+ const client = fakeClient({ ok: true, status: 201, data: {} });
94
+ const visual = { buf: PNG, contentType: 'image/png', alt: 'cap', name: 'after.png' };
95
+ const r = await (async () => {
96
+ const saved = console.log; const out = [];
97
+ console.log = (...a) => out.push(a.join(' '));
98
+ try { return { ok: await shipVisual.upload({ taskId: 7, visual, client, errorLine: () => '' }), out }; } finally { console.log = saved; }
99
+ })();
100
+ assert.equal(r.ok, true);
101
+ assert.equal(client.calls.length, 1);
102
+ assert.equal(client.calls[0].id, 7);
103
+ assert.equal(client.calls[0].body.image_b64, PNG.toString('base64'));
104
+ assert.equal(client.calls[0].body.alt, 'cap');
105
+ });
106
+
107
+ test('a failed upload is a warning naming the manual re-attach, never a failure', async () => {
108
+ const client = fakeClient({ ok: false, status: 403, data: { error: 'nope' } });
109
+ const visual = { buf: PNG, contentType: 'image/png', alt: '', name: 'after.png' };
110
+ const saved = console.warn; const warn = [];
111
+ console.warn = (...a) => warn.push(a.join(' '));
112
+ let ok;
113
+ try { ok = await shipVisual.upload({ taskId: 7, visual, client, errorLine: (d, s) => `HTTP ${s}` }); } finally { console.warn = saved; }
114
+ assert.equal(ok, false);
115
+ assert.match(warn.join('\n'), /node scripts\/gds\/task\.js visual 7 <image>/);
116
+ });
117
+
118
+ test('no visual uploads nothing', async () => {
119
+ const client = fakeClient({ ok: true });
120
+ assert.equal(await shipVisual.upload({ taskId: 7, visual: null, client, errorLine: () => '' }), false);
121
+ assert.equal(client.calls.length, 0);
122
+ });
123
+
124
+ // ---- wiring ---------------------------------------------------------------
125
+
126
+ const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
127
+ const stripComments = (s) => s.replace(/^\s*\/\/[^\n]*$/gm, '');
128
+ const regrade = stripComments(src.slice(src.indexOf('async function regradeMain'), src.indexOf('module.exports')));
129
+ const ship = stripComments(src.slice(src.indexOf('async function shipMain'), src.indexOf('async function regradeMain')));
130
+
131
+ test('regradeMain screens --visual early, before the preflight and the panel', () => {
132
+ const screen = regrade.indexOf('shipVisual.fromArgs(args, { regrade: true })');
133
+ assert.ok(screen !== -1, 'regradeMain screens the flag itself');
134
+ assert.ok(screen < regrade.indexOf('preflight(claimLike'), 'before the preflight');
135
+ assert.ok(screen < regrade.indexOf('gradeAndRecord('), 'before any grade spend');
136
+ });
137
+
138
+ test('every path regradeMain takes to grade or land attaches the visual first', () => {
139
+ // Three ways out that do real work: the held re-publish, the artifact-free
140
+ // confirm, and the graded path. Each finishShip must be preceded by an attach
141
+ // that sits AFTER the previous finishShip — i.e. one attach per path.
142
+ const finishes = [...regrade.matchAll(/await finishShip\(/g)].map((m) => m.index);
143
+ const attaches = [...regrade.matchAll(/await attachVisual\(\)/g)].map((m) => m.index);
144
+ assert.equal(finishes.length, 3, 'three landing paths (update this pin if one is added)');
145
+ assert.equal(attaches.length, 3, 'one attach per landing path');
146
+ let prev = -1;
147
+ finishes.forEach((f, i) => {
148
+ assert.ok(attaches[i] > prev && attaches[i] < f, `landing path ${i + 1} attaches before it lands`);
149
+ prev = f;
150
+ });
151
+ // The graded path attaches before the panel runs, after the summary persists.
152
+ const graded = attaches[2];
153
+ assert.ok(regrade.indexOf('await persistRegradeSummary(') < graded, 'after every guard that can refuse');
154
+ assert.ok(graded < regrade.indexOf('gradeAndRecord('), 'before the grade');
155
+ // The artifact-free path attaches before the confirm call lands it.
156
+ assert.ok(attaches[1] < regrade.indexOf('postTasksIdConfirm('), 'before the manual confirm');
157
+ });
158
+
159
+ test('a plain re-ship that resumes the grade hands over the visual it screened', () => {
160
+ assert.match(ship, /return regradeMain\(\{ visual \}\)/);
161
+ assert.match(regrade, /opts\.visual !== undefined \? opts\.visual : shipVisual\.fromArgs/);
162
+ });
163
+
164
+ test('the re-grade usage line advertises --visual', () => {
165
+ const usage = regrade.slice(0, regrade.indexOf('process.exit(2)'));
166
+ assert.match(usage, /--regrade [^\n]*--visual <image>/);
167
+ });