@bongos/core 1.19.690 → 1.19.692

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.19.690",
6
- "core_contract": "1.19.690",
7
- "source_commit": "60679343d4642880b9d09e266fdf8847948e63c4",
5
+ "core_version": "1.19.692",
6
+ "core_contract": "1.19.692",
7
+ "source_commit": "012d6894a38ec2863be3f253053d3e6bcb153e6e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T00:15:19.171Z",
9
+ "built_at": "2026-09-13T01:33:50.871Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2126,
14
+ "functional_verbatim": 2127,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2627,
20
- "tree_sha256": "62df4f01b358cfb184df394a6f373977f6638d2b4ce8f58df8e78f66259bfb95",
19
+ "file_count": 2628,
20
+ "tree_sha256": "5094b3b7495543b7ffaed3e1fc5f1b10c24356b3e227ad469461127b5cbefec7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "a8b10d6556ee013925b76fab635bbf14e1299216be69b54959b07de8cf47b8da"
2805
+ "sha256": "ecd1242e375b1219b305ac16e2c3da7bfd8bdaa6812fd595b1ff9c68e84a8883"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -5952,7 +5952,7 @@
5952
5952
  {
5953
5953
  "path": "modules/lifecycle/routes/tasks.js",
5954
5954
  "mode": "0000644",
5955
- "sha256": "456b1d53d2349a499e1b07897dc6e16c72d26156a71347382c5a2508dc94d28d"
5955
+ "sha256": "6d48a213e2ab334720833c174f4d5a1b9f8133404dbb21898305ae1c1a5db9d7"
5956
5956
  },
5957
5957
  {
5958
5958
  "path": "modules/lifecycle/routes/version-route-authz.js",
@@ -7782,12 +7782,12 @@
7782
7782
  {
7783
7783
  "path": "package-lock.json",
7784
7784
  "mode": "0000644",
7785
- "sha256": "260ff5666981dd27b2112c4e0ed8618761d10fe9c505630e3b75eaac3d588f73"
7785
+ "sha256": "5551c94d2db16d2ca8587eb4eeb10a6a92c03f303c578440ecda87bdbd8d96f8"
7786
7786
  },
7787
7787
  {
7788
7788
  "path": "package.json",
7789
7789
  "mode": "0000644",
7790
- "sha256": "6164b80832d080ed4ec27d8e4678ffbbab1344cf876af1b46702e5e4e2988924"
7790
+ "sha256": "ee34bdc1c10e92539590e5df75dddd41aca8db38e79579ba97fb78511f096558"
7791
7791
  },
7792
7792
  {
7793
7793
  "path": "public-docs/index.html",
@@ -8922,7 +8922,7 @@
8922
8922
  {
8923
8923
  "path": "scripts/gds/ship-flow.js",
8924
8924
  "mode": "0000644",
8925
- "sha256": "8bf28a3d4d15e83a1e94416ec2519d39bc2a3faa71303a4f2d22d429b6afaa95"
8925
+ "sha256": "7f6c46f56fcd54c01b5dd0bd6fecd712962c87ed693a44bea1746cf4c6aacebb"
8926
8926
  },
8927
8927
  {
8928
8928
  "path": "scripts/gds/ship-git.js",
@@ -8937,7 +8937,7 @@
8937
8937
  {
8938
8938
  "path": "scripts/gds/ship-grade.js",
8939
8939
  "mode": "0000644",
8940
- "sha256": "51b9132fc77f5f639a4597b26a6a5d083bf43c56944bf6710c939cafa074ba90"
8940
+ "sha256": "79fcf45dd254681e7f91fe83fbb8a208b30c93eebe77df569aae505a025daa68"
8941
8941
  },
8942
8942
  {
8943
8943
  "path": "scripts/gds/ship-honesty.js",
@@ -8977,7 +8977,7 @@
8977
8977
  {
8978
8978
  "path": "scripts/gds/ship-resume.js",
8979
8979
  "mode": "0000644",
8980
- "sha256": "4d56098b0b7df003e7efece23b8d1bca55dd272774117cd75074a1c7609939ec"
8980
+ "sha256": "20b91c52c75fae6355165941d1467081424614d46063f87137870ef291d442b9"
8981
8981
  },
8982
8982
  {
8983
8983
  "path": "scripts/gds/ship-session-upload.js",
@@ -9002,7 +9002,7 @@
9002
9002
  {
9003
9003
  "path": "scripts/gds/ship.js",
9004
9004
  "mode": "0000644",
9005
- "sha256": "6fd0329b8ad7d45fbb93deda02142c2b766c9e01b669d2d94eeafcb1caa225b0"
9005
+ "sha256": "a64455fdf9771e4ccb04b49d42b9bb0cb0fe45bc6cb36d4e5f3abe0c31fe99ff"
9006
9006
  },
9007
9007
  {
9008
9008
  "path": "scripts/gds/skill-lint.js",
@@ -9422,7 +9422,7 @@
9422
9422
  {
9423
9423
  "path": "src/bongos/routes.js",
9424
9424
  "mode": "0000644",
9425
- "sha256": "074cded727667d864b1849d8d5505736895a69a6bdf7132375fd358d66c2e6fd"
9425
+ "sha256": "d02ccda5895a3662fe1ebb74785fd1b4745351f2a1532e2bd45aab7b18c297a1"
9426
9426
  },
9427
9427
  {
9428
9428
  "path": "src/bongos/routes/CLAUDE.md",
@@ -9552,7 +9552,7 @@
9552
9552
  {
9553
9553
  "path": "src/module-api.js",
9554
9554
  "mode": "0000644",
9555
- "sha256": "3db8b41741ace6b9070683824e39c1fa8e7c2724bef4585e91734ffd43afa382"
9555
+ "sha256": "2e95562bb87ada79174e5014569717a5583925c9d2de108e1fb2e74763cf531b"
9556
9556
  },
9557
9557
  {
9558
9558
  "path": "src/module-loader/catalog.js",
@@ -11034,6 +11034,11 @@
11034
11034
  "mode": "0000644",
11035
11035
  "sha256": "f32c3de05d534befef5f2b5e199b77e11fa7d7a20bdf65ff70cba0c32b8d2060"
11036
11036
  },
11037
+ {
11038
+ "path": "tests/grade_body_limit.mjs",
11039
+ "mode": "0000644",
11040
+ "sha256": "7bd3fecff64c1fb2b4abbbd3bd7e836a7317d1018845a69f2c4adde05ea15273"
11041
+ },
11037
11042
  {
11038
11043
  "path": "tests/grade_diff_hash.mjs",
11039
11044
  "mode": "0000644",
@@ -12677,7 +12682,7 @@
12677
12682
  {
12678
12683
  "path": "tests/ship_resume.mjs",
12679
12684
  "mode": "0000644",
12680
- "sha256": "2b2ef91bda46ab9a764709ab30b97de6e184a3ab2b86c4b7cb8f1c168fc0a5b7"
12685
+ "sha256": "e5305fa140de31d7e646e328b4a3956f845f62b335bf14285a91cc38a20f8e0f"
12681
12686
  },
12682
12687
  {
12683
12688
  "path": "tests/ship_smoke_gate.mjs",
@@ -1839,5 +1839,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1839
1839
  landed since 1.19.688 with no explicit bump. run 34726470555. (task 1002620)
1840
1840
  1.19.690 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1841
1841
  landed since 1.19.689 with no explicit bump. run 34727464797. (task 1002620)
1842
+ 1.19.691 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1843
+ landed since 1.19.690 with no explicit bump. run 34729018766. (task 1002620)
1844
+ 1.19.692 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1845
+ landed since 1.19.691 with no explicit bump. run 34730821372. (task 1002620)
1842
1846
  ---------------------------------------------------------------------------
1843
1847
  ```
@@ -56,6 +56,10 @@ const { branding } = api;
56
56
  // above the 20 MB decoded ceiling. The decoded bytes are re-checked in the
57
57
  // handler (the JSON `limit` only bounds the ENCODED payload — SR-17 posture).
58
58
  const PUBLISH_BODY_LIMIT = '32mb';
59
+ // The grade body is committed_files (one entry per changed path) + the panel's findings.
60
+ // 1 MB covers a repo-wide change with room to spare and still bounds the route; the global
61
+ // 64 KB cap is skipped for this path in src/bongos/routes.js. See task 1003850.
62
+ const GRADE_BODY_LIMIT = '1mb';
59
63
  const PUBLISH_BUNDLE_B64_MAX = 30 * 1024 * 1024; // validator guard on the b64 string
60
64
  const PUBLISH_BUNDLE_MAX_BYTES = 20 * 1024 * 1024; // 20 MB decoded bundle
61
65
 
@@ -881,6 +885,11 @@ module.exports = function buildTasksRouter() {
881
885
  }
882
886
  });
883
887
 
888
+ // Declared ABOVE the doc-comment on purpose: gen-api-docs.js reads only the `//`
889
+ // lines immediately preceding `router.post(`, so a const wedged between them blanks
890
+ // this endpoint's whole description in docs/api-reference.md and openapi.json.
891
+ const gradeUpload = express.json({ limit: GRADE_BODY_LIMIT });
892
+
884
893
  // GDS-V3 task V3.R18 (#209). Subagent grader credits gate.
885
894
  //
886
895
  // ship.js (or any caller) spawns an Opus subagent against the diff + task
@@ -905,7 +914,7 @@ module.exports = function buildTasksRouter() {
905
914
  // rank: any-builder — grader writes a row + auto-promotes only if grade clears
906
915
  // threshold; the grader subagent is the objectivity guarantee. Supervisor hot
907
916
  // path (preserved per CRITICAL §4 — no requireRank).
908
- router.post('/tasks/:id/grade', auth.requireBuilder, async (req, res) => {
917
+ router.post('/tasks/:id/grade', auth.requireBuilder, gradeUpload, async (req, res) => {
909
918
  // Permissive on subagent_output type — the grader handles both object AND
910
919
  // JSON-string forms (see tests/grader.mjs "accepts JSON-string input").
911
920
  // Only enforce presence + light shape on signals (extras pass through).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.690",
3
+ "version": "1.19.692",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.690",
9
+ "version": "1.19.692",
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.19.690",
3
+ "version": "1.19.692",
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",
@@ -27,7 +27,7 @@ const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./
27
27
  const { pushVia } = require('./ship-deploy-target.js');
28
28
 
29
29
  const { diffstatFor, finishShip } = require('./ship-finish.js');
30
- const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge } = require('./ship-resume.js');
30
+ const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
31
31
  const { noteServerReward } = require('./ship-state.js');
32
32
 
33
33
  // ---------- Main flow ----------
@@ -850,8 +850,66 @@ async function regradeMain() {
850
850
  const graderBaseline = computeGraderBaseline(claimLike);
851
851
  const committedFiles = computeChangedFiles(graderBaseline);
852
852
  if (committedFiles.length === 0) {
853
+ // task 1003147: a DELIBERATELY artifact-free task used to dead-end here.
854
+ // shipMain routes every 'completed' task to this re-grade BEFORE reading any
855
+ // flag (evaluateResumeAction takes none), so --allow-empty / --skip-grade never
856
+ // reached the manual-confirm path that a no-artifact task actually needs — and
857
+ // that path is unreachable from 'completed' any other way. Hit for real on task
858
+ // 1003146: a kind=verify task landed at 'completed' with no grade row, and
859
+ // --allow-empty, --skip-grade and --verified all bailed on this same line.
860
+ //
861
+ // BOTH conditions are required, and the diff emptiness is MEASURED, not taken
862
+ // on the builder's word. A flag alone must not reach confirm: a task with a
863
+ // real diff whose grade failed would then bypass the grader by passing
864
+ // --allow-empty, which is a quality hole, not a fix. Here the grader has
865
+ // nothing to read, so there is no verdict to dodge. Confirming is additionally
866
+ // permission-gated server-side on task.confirm.grade_bypass, so this widens
867
+ // no authority — it only stops the ONE shape that had no exit at all.
868
+ if (shouldConfirmWithoutGrade({ committedFileCount: committedFiles.length, args })) {
869
+ console.log('');
870
+ console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
871
+ console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
872
+ // A kind='verify' task cannot reach here without --verified: the gate above
873
+ // exits first, so the server's verify_attestation_required can't fire below.
874
+ const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
875
+ if (!confirm.ok) {
876
+ const errObj = confirm.data && confirm.data.error;
877
+ const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
878
+ if (confirm.status === 403 && errCode === 'permission_forbidden') {
879
+ const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
880
+ const floor = details.floors && details.floors['task.confirm.grade_bypass'];
881
+ console.error('');
882
+ console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floor ? ` (floor: ${floor})` : ''}.`);
883
+ console.error(' This is an authorization wall, not a verdict on your work.');
884
+ console.error(` Ask a builder whose role holds it to POST /api/gds/tasks/${taskId}/confirm.`);
885
+ process.exit(1);
886
+ }
887
+ console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
888
+ process.exit(1);
889
+ }
890
+ const confirmedStatus = confirm.data.taskStatus;
891
+ printCreditsAndAchievements({
892
+ postGradeStatus: confirmedStatus,
893
+ credits: confirm.data.creditsAwarded ?? 0,
894
+ creditsRaw: null,
895
+ creditsMultiplier: null,
896
+ ideaBonusInfo: confirm.data.ideaBonus ?? null,
897
+ justUnlocked: confirm.data.just_unlocked || [],
898
+ });
899
+ // The same confirmed→shipped tail a normal ship runs, so a no-artifact resume
900
+ // lands identically instead of stopping one step short.
901
+ await finishShip({
902
+ taskId, summary, advanceToMerge: confirmedStatus === 'confirmed', skipMerge, dbOnly: true,
903
+ postGradeStatus: confirmedStatus,
904
+ doneLine: 'Done — confirmed with no artifact (grader had nothing to read).',
905
+ card: { title: task.title, valueSummary: summary, notes: '' },
906
+ });
907
+ return;
908
+ }
853
909
  console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
854
910
  console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
911
+ console.error('If this task is DELIBERATELY artifact-free (a DB-only close, an ops verification), say so:');
912
+ console.error(` node scripts/gds/ship.js ${taskId} --allow-empty`);
855
913
  process.exit(1);
856
914
  }
857
915
 
@@ -111,10 +111,34 @@ function computeChangedFiles(baseline) {
111
111
  return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
112
112
  }
113
113
 
114
+ // How much diff we are willing to CAPTURE. Distinct from GRADER_DIFF_CHAR_CAP, which is
115
+ // how much we are willing to SHOW the panel: the capture has to succeed first, and a
116
+ // capture that FAILS is indistinguishable from "nothing changed" unless we look.
117
+ const DIFF_CAPTURE_MAX_BYTES = 512 * 1024 * 1024;
118
+
114
119
  function computeDiffText(baseline) {
115
120
  if (!baseline) return '';
116
121
  // -M to track renames; --no-color so the subagent gets clean diff lines.
117
- const diff = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
122
+ //
123
+ // maxBuffer is set EXPLICITLY, and that is the whole point of this function. execSync
124
+ // defaults to 1 MB and gitOk swallows the resulting ENOBUFS as null, so a large change
125
+ // silently produced an EMPTY diff: the panel then graded a zero-character string and
126
+ // would have returned a verdict on nothing at all, reporting diff_chars=0 in a log line
127
+ // that reads like an ordinary stat rather than an error. A 1,141-file rename (task
128
+ // 1003705) measured 9,808,703 chars and is what surfaced it; every big change had the
129
+ // same hole. The GRADER_DIFF_CHAR_CAP truncation below still bounds what the subagent is
130
+ // asked to read — this only stops the capture itself from failing.
131
+ const diff = gitOk(`git diff --no-color -M ${baseline}...HEAD`, { maxBuffer: DIFF_CAPTURE_MAX_BYTES });
132
+ if (diff === null) {
133
+ // Capture failed even at that size. Fall back to the stat summary so the panel gets the
134
+ // SHAPE of the change and can Read any file in its post-resolve state — strictly better
135
+ // than an empty string, which reads as "no changes" and grades as a pass.
136
+ // gitOk returns null ONLY on failure; a genuinely empty diff comes back as ''.
137
+ const stat = gitOk(`git diff --no-color -M --stat ${baseline}...HEAD`, { maxBuffer: 64 * 1024 * 1024 });
138
+ return stat
139
+ ? `[diff too large to capture in full — stat summary only. Read any file for its post-resolve state.]\n\n${stat}`
140
+ : '';
141
+ }
118
142
  if (!diff) return '';
119
143
  if (diff.length > GRADER_DIFF_CHAR_CAP) {
120
144
  const head = diff.slice(0, GRADER_DIFF_CHAR_CAP);
@@ -147,11 +171,25 @@ function diffHash(rawDiff) {
147
171
  }
148
172
 
149
173
  // Compute the hash of the full (untruncated) diff against baseline. Returns
150
- // null when baseline is unavailable so callers can treat null as "unknown".
174
+ // null when baseline is unavailable, OR when the diff cannot be captured, so
175
+ // callers can treat null as "unknown".
176
+ //
177
+ // SAME BUFFER BUG AS computeDiffText, AND A WORSE CONSEQUENCE. This ran gitOk at
178
+ // execSync's default 1 MB, so a large diff came back null and the old
179
+ // `diffHash(raw || '')` hashed the EMPTY STRING — the same fixed SHA-256 for every
180
+ // large diff. shouldReuseGrade compares that hash against the stored grade's
181
+ // diff_hash, so two genuinely different large diffs on one task collided and a stale
182
+ // PASS could be reused on changed code, skipping the panel outright. That is the
183
+ // silent-pass failure this task exists to remove, reappearing in the reuse path.
184
+ //
185
+ // Returning null on a failed capture is the fail-safe answer, not a cosmetic one:
186
+ // shouldReuseGrade already refuses a falsy currentDiffHash, so "unknown" now means
187
+ // "run the panel", where "" meant "matches every other unknown".
151
188
  function computeRawDiffHash(baseline) {
152
189
  if (!baseline) return null;
153
- const raw = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
154
- return diffHash(raw || '');
190
+ const raw = gitOk(`git diff --no-color -M ${baseline}...HEAD`, { maxBuffer: DIFF_CAPTURE_MAX_BYTES });
191
+ if (raw === null) return null; // capture failed — never collapse that to hash('')
192
+ return diffHash(raw);
155
193
  }
156
194
 
157
195
  // Pure predicate: should we reuse the stored grade instead of running the
@@ -106,6 +106,27 @@ function evaluateResumeAction({ task, lastClaim, builder }) {
106
106
  return { action: 'error', reason: `Task #${task.id} is '${status}'. Nothing to resume.` };
107
107
  }
108
108
 
109
+ // task 1003147: may a re-grade that has NOTHING to grade manual-confirm instead of
110
+ // dead-ending? Pure + exported so the truth table is unit-tested with no git or
111
+ // network, the same contract evaluateResumeAction above carries.
112
+ //
113
+ // Both halves are required, and they are different KINDS of evidence. The empty
114
+ // diff is MEASURED (the caller passes what `git diff --name-only` actually
115
+ // returned); the flag is the builder's DECLARATION that artifact-free is intended.
116
+ // A declaration alone must never be enough — a task with a real diff whose grade
117
+ // failed would otherwise bypass the grader by passing --allow-empty, turning a
118
+ // deadlock fix into a quality hole. When the diff is genuinely empty the grader has
119
+ // nothing to read, so there is no verdict being dodged.
120
+ //
121
+ // A bare re-run (neither flag) keeps dead-ending, deliberately: the overwhelmingly
122
+ // common cause of an empty diff is uncommitted work, and silently confirming that
123
+ // would ship nothing while reporting success.
124
+ function shouldConfirmWithoutGrade({ committedFileCount, args = [] }) {
125
+ const emptyDiff = Number(committedFileCount) === 0;
126
+ const declaredNoArtifact = args.includes('--allow-empty') || args.includes('--skip-grade');
127
+ return emptyDiff && declaredNoArtifact;
128
+ }
129
+
109
130
  // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
110
131
  // (it already passed the grade + earned credits; this is the leg that stalls when
111
132
  // a box can't reach the server publish, a poll window expires, etc.). Mirrors the
@@ -148,4 +169,5 @@ module.exports = {
148
169
  evaluateRegradeEligibility,
149
170
  evaluateResumeAction,
150
171
  resumeMerge,
172
+ shouldConfirmWithoutGrade,
151
173
  };
@@ -152,7 +152,7 @@ const {
152
152
  formatGradeOutcome,
153
153
  gradeAndRecord,
154
154
  } = require('./ship-grade-steps.js');
155
- const { evaluateRegradeEligibility, evaluateResumeAction } = require('./ship-resume.js');
155
+ const { evaluateRegradeEligibility, evaluateResumeAction, shouldConfirmWithoutGrade } = require('./ship-resume.js');
156
156
  const { regradeMain, shipMain } = require('./ship-flow.js');
157
157
  const { fatalRecoveryLines, smokeGateMessages } = require('./ship-honesty.js');
158
158
  const { chooseBroadcastTargets } = require('../../modules/discord/ship-broadcast');
@@ -213,6 +213,10 @@ module.exports = {
213
213
  // re-run — exported so the truth table (completed→grade, confirmed→merge,
214
214
  // ownership, terminal states) is unit-tested with no git/network.
215
215
  evaluateResumeAction,
216
+ // task 1003147: whether a re-grade with NOTHING to grade may manual-confirm
217
+ // instead of dead-ending — exported so the truth table (empty diff + declared
218
+ // no-artifact → confirm; a real diff, or no declaration → never) is unit-tested.
219
+ shouldConfirmWithoutGrade,
216
220
  // #747: pre-ship auto-linkify of the ship's own changed docs. The path
217
221
  // predicate is pure + exported so the regression test can assert which
218
222
  // changed files fall in the linkifier's domain without git/fs/API.
@@ -94,7 +94,15 @@ function buildGdsRouter() {
94
94
  // task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
95
95
  // encoded (4 MB decoded). Same dynamic-:id pattern as publish-branch above;
96
96
  // modules/lifecycle/routes/task-visuals.js mounts its own 8 MB parser.
97
- /^\/tasks\/\d+\/visual$/.test(req.path)
97
+ /^\/tasks\/\d+\/visual$/.test(req.path) ||
98
+ // task 1003850: the grade body carries committed_files — EVERY changed path, tens of
99
+ // KB on its own for a mass change — plus the panel's findings. A 1,141-file rename
100
+ // (task 1003705) produced a body over the 64 KB cap, so the grade could not be
101
+ // recorded at all (413 payload_too_large) and fully verified work stranded at
102
+ // 'completed'. The list is NOT trimmable to fit: modules/grading/grader-score.js runs
103
+ // the server-side permission-path pre-pass against it (SR-9 / SEC #863), so a short
104
+ // list would quietly weaken a security check rather than merely lose detail.
105
+ /^\/tasks\/\d+\/grade$/.test(req.path)
98
106
  ) return next();
99
107
  return globalJson(req, res, next);
100
108
  });
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.690'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.692'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,154 @@
1
+ // tests/grade_body_limit.mjs — the grade body is not capped at 64 KB (task 1003850).
2
+ //
3
+ // WHAT BROKE. A 1,141-file rename produced a grade body over the global 64 KB JSON cap,
4
+ // so POST /tasks/:id/grade came back 413 payload_too_large and fully verified work was
5
+ // stranded at 'completed' with no way to record its grade. --regrade fails identically:
6
+ // the size is deterministic, not transient.
7
+ //
8
+ // WHY THE BODY CANNOT SIMPLY BE TRIMMED. committed_files carries EVERY changed path, and
9
+ // it is not decoration: modules/grading/grader-score.js runs the server-side
10
+ // permission-path pre-pass against that exact list (SR-9 / SEC #863), so a shortened list
11
+ // would quietly weaken a security check rather than just lose detail. The body gets its
12
+ // own parser instead — the same shape memory/sync, sessions, publish-branch and the task
13
+ // visual already use.
14
+ //
15
+ // Two halves, and BOTH are required: the global parser must SKIP this path (otherwise it
16
+ // 413s before the route-scoped one ever runs — the comment in routes.js says exactly
17
+ // this), and the route must mount a larger parser of its own.
18
+
19
+ import test from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
27
+
28
+ const GRADE_PATH = '/tasks/1003850/grade';
29
+
30
+ test('the global 64 KB parser SKIPS the grade path', () => {
31
+ const src = read('src/bongos/routes.js');
32
+
33
+ // The cap itself is still 64 KB for everything else — this test is about the exemption,
34
+ // not about loosening the default.
35
+ assert.match(src, /express\.json\(\{ limit: '64kb' \}\)/,
36
+ 'the global cap should stay 64 KB; only named paths are exempt');
37
+
38
+ // Pull the skip predicate's path patterns straight out of the source and run them, so
39
+ // this asserts the REAL routing decision rather than the presence of a comment.
40
+ const block = src.slice(src.indexOf('const globalJson'), src.indexOf('// Audit middleware'));
41
+ // Regex LITERALS of the form /^...$/ — the body may contain escaped slashes, so the
42
+ // scan has to allow `\.` rather than stopping at the first `/`.
43
+ const patterns = [...block.matchAll(/\/\^(?:\\.|[^/\n])*\$\//g)].map((m) => m[0]);
44
+ assert.ok(patterns.length >= 3, `expected the skip list to carry several path regexes, saw ${patterns.length}`);
45
+
46
+ const matched = patterns.some((lit) => {
47
+ // eslint-disable-next-line no-new-func -- the literal comes from our own source
48
+ const re = new Function(`return ${lit}`)();
49
+ return re.test(GRADE_PATH);
50
+ });
51
+ assert.ok(matched, `no skip-list pattern matches ${GRADE_PATH} — the global parser would 413 the body before the route's own parser runs`);
52
+ });
53
+
54
+ test('the grade route mounts its own, larger parser', () => {
55
+ const src = read('modules/lifecycle/routes/tasks.js');
56
+
57
+ const m = /const GRADE_BODY_LIMIT = '(\d+)(kb|mb)';/.exec(src);
58
+ assert.ok(m, 'GRADE_BODY_LIMIT is gone — the route would fall back to whatever parser ran first');
59
+
60
+ const bytes = Number(m[1]) * (m[2] === 'mb' ? 1024 * 1024 : 1024);
61
+ assert.ok(bytes > 64 * 1024,
62
+ `GRADE_BODY_LIMIT (${m[1]}${m[2]}) must exceed the 64 KB global cap or the exemption buys nothing`);
63
+
64
+ // Mounted ON the route, ahead of the handler.
65
+ assert.match(src, /const gradeUpload = express\.json\(\{ limit: GRADE_BODY_LIMIT \}\);/,
66
+ 'the route-scoped parser is not built from GRADE_BODY_LIMIT');
67
+ // Mounted ON the route. The ORDER (auth first) is asserted separately below; pinning it
68
+ // here too would mean changing the same fact in two places.
69
+ assert.match(src, /router\.post\('\/tasks\/:id\/grade',[^)]*gradeUpload,/,
70
+ 'the grade route must run its own parser before the handler');
71
+ });
72
+
73
+ test('a realistic mass-change body fits the new limit', () => {
74
+ // The shape that broke it: one path per changed file, plus the panel's findings. 1,141
75
+ // paths measured ~38 KB of JSON on their own — comfortably over 64 KB once the grade
76
+ // output is added, and comfortably under 1 MB.
77
+ const files = Array.from({ length: 1200 }, (_, i) => `scripts/gds/some-fairly-long-script-name-${i}.js`);
78
+ const body = JSON.stringify({
79
+ committed_files: files,
80
+ subagent_output: { functional_fidelity: 9, code_quality: 9, notes: 'x'.repeat(20_000) },
81
+ });
82
+ assert.ok(body.length > 64 * 1024,
83
+ 'this fixture is meant to EXCEED the old cap — if it does not, it is not reproducing the bug');
84
+ assert.ok(body.length < 1024 * 1024,
85
+ `a mass-change body (${body.length} bytes) must fit the route limit`);
86
+ });
87
+
88
+ // ── the two findings the panel raised on the first round ────────────────────────────────
89
+
90
+ test('the LARGER parser runs AFTER auth, like every other exempted route here', () => {
91
+ // Raised by the panel, round 1. Mounting the 1 MB parser before requireBuilder lets an
92
+ // UNAUTHENTICATED caller make the server parse up to 1 MB — sixteen times the global cap
93
+ // — before it is rejected. This file already had the right shape twice over; the new
94
+ // route simply diverged from it.
95
+ const src = read('modules/lifecycle/routes/tasks.js');
96
+
97
+ assert.match(src, /router\.post\('\/tasks\/:id\/grade', auth\.requireBuilder, gradeUpload,/,
98
+ 'the grade route must authenticate BEFORE parsing a larger body');
99
+
100
+ // The convention this follows, asserted so the three stay in step.
101
+ assert.match(src, /router\.post\('\/tasks\/:id\/publish-branch', auth\.requireBuilder, publishUpload,/,
102
+ 'publish-branch is the precedent for auth-then-parser');
103
+ });
104
+
105
+ test('computeRawDiffHash does not collapse an uncapturable diff to hash("")', () => {
106
+ // The worse half of the same buffer bug, and the panel caught it: computeDiffText was
107
+ // fixed while its sibling was not. At the default 1 MB buffer a large diff returned null
108
+ // and the old code hashed the EMPTY STRING — one fixed SHA-256 shared by every large
109
+ // diff. shouldReuseGrade compares that hash to the stored grade's, so two different large
110
+ // diffs on one task collided and a stale PASS could be reused on changed code, skipping
111
+ // the panel entirely.
112
+ const src = read('scripts/gds/ship-grade.js');
113
+
114
+ const fn = src.slice(src.indexOf('function computeRawDiffHash'), src.indexOf('function shouldReuseGrade'));
115
+ assert.ok(fn.length > 0, 'computeRawDiffHash is gone');
116
+ assert.match(fn, /maxBuffer: DIFF_CAPTURE_MAX_BYTES/,
117
+ 'computeRawDiffHash must capture at the same size as computeDiffText');
118
+ assert.match(fn, /if \(raw === null\) return null;/,
119
+ 'a failed capture must be "unknown" (null), never hash(\'\')');
120
+ assert.doesNotMatch(fn, /diffHash\(raw \|\| ''\)/,
121
+ 'hashing `raw || \'\'` is the bug: every uncapturable diff hashes identically');
122
+
123
+ // And "unknown" must actually refuse reuse, which is what makes null the safe answer.
124
+ const reuse = src.slice(src.indexOf('function shouldReuseGrade'), src.indexOf('function shouldReuseGrade') + 400);
125
+ assert.match(reuse, /!currentDiffHash/, 'shouldReuseGrade must refuse a null/absent current hash');
126
+ });
127
+
128
+ test('both diff captures use one shared size, not two drifting numbers', () => {
129
+ // The two call sites are the same decision; a second literal is how one gets fixed and
130
+ // the other does not — which is exactly what happened here.
131
+ const src = read('scripts/gds/ship-grade.js');
132
+ assert.match(src, /const DIFF_CAPTURE_MAX_BYTES = /, 'the capture size must be named once');
133
+ const uses = (src.match(/maxBuffer: DIFF_CAPTURE_MAX_BYTES/g) || []).length;
134
+ assert.equal(uses, 2, `both diff captures must share the constant, saw ${uses}`);
135
+ });
136
+
137
+ test('the grade endpoint keeps its full generated description', () => {
138
+ // Panel finding, round 1 — and I doubted it before measuring, which was the wrong
139
+ // instinct. gen-api-docs' commentBlockAbove reads only the `//` lines IMMEDIATELY above
140
+ // `router.post(`, so declaring the parser between the doc-comment and the route collapses
141
+ // this endpoint's description from 1251 chars to 94: the rank line survives and every
142
+ // word about what the endpoint does, and how it fails, is dropped from BOTH
143
+ // docs/api-reference.md and openapi.json. Measured both ways before believing it.
144
+ //
145
+ // Pinned on LENGTH rather than exact prose so ordinary edits to the comment do not red
146
+ // this, while the collapse (which is an order-of-magnitude drop) still does.
147
+ const spec = JSON.parse(read('docs/api/openapi.json'));
148
+ const desc = spec.paths['/tasks/{id}/grade']?.post?.description || '';
149
+ assert.ok(desc.length > 400,
150
+ `the grade endpoint's generated description collapsed to ${desc.length} chars — `
151
+ + 'something now sits between its doc-comment and router.post(');
152
+ assert.match(desc, /[Ss]ubagent grader/,
153
+ 'the description should still explain what the endpoint is');
154
+ });
@@ -17,9 +17,11 @@
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
19
 
20
+ import { shipCliSource } from './helpers.mjs';
21
+
20
22
  const require = createRequire(import.meta.url);
21
23
  const ship = require('../scripts/gds/ship.js');
22
- const { evaluateResumeAction } = ship;
24
+ const { evaluateResumeAction, shouldConfirmWithoutGrade } = ship;
23
25
 
24
26
  let passed = 0;
25
27
  let failed = 0;
@@ -124,6 +126,57 @@ test('completed + owner with low rank still resumes (ownership, not rank)', () =
124
126
  assert.equal(a.via, 'owner');
125
127
  });
126
128
 
129
+ // ---- task 1003147: the artifact-free resume that used to deadlock ----------
130
+ // HIT FOR REAL on task 1003146. A kind=verify task landed at 'completed' with no
131
+ // grade row; every documented escape (--allow-empty, --skip-grade, --verified) was
132
+ // swallowed by the same "Nothing to grade" bail, because shipMain routes ANY
133
+ // 'completed' task into the re-grade before reading a flag. The manual-confirm
134
+ // path a no-artifact task needs is unreachable from 'completed' any other way.
135
+
136
+ test('empty diff + --allow-empty → confirm instead of bailing (the 1003146 shape)', () => {
137
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--allow-empty'] }), true);
138
+ });
139
+
140
+ test('empty diff + --skip-grade → confirm (the other documented no-artifact flag)', () => {
141
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--skip-grade'] }), true);
142
+ });
143
+
144
+ test('a REAL diff + --allow-empty still grades — the flag is not a grader bypass', () => {
145
+ // The hole this guard exists to avoid: if a declaration alone were enough, a task
146
+ // whose grade FAILED could dodge the verdict by re-running with --allow-empty.
147
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, args: ['1003146', '--allow-empty'] }), false);
148
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, args: ['1003146', '--skip-grade'] }), false);
149
+ });
150
+
151
+ test('empty diff with NO flag keeps dead-ending — uncommitted work must not "ship"', () => {
152
+ // The overwhelmingly common cause of an empty diff is forgetting to commit.
153
+ // Confirming that silently would report success having shipped nothing.
154
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146'] }), false);
155
+ });
156
+
157
+ test('the empty-diff guard is actually WIRED to the decision, not just exporting it', () => {
158
+ // A pure truth table proves the decision, not that anything calls it. Without
159
+ // this, the guard could revert to its unconditional `process.exit(1)` and every
160
+ // case above would still pass — the deadlock back, the tests still green.
161
+ const src = shipCliSource();
162
+ // Match the CALL, not the declaration. shipCliSource() spans the whole ship
163
+ // family including ship-resume.js, where the function is DEFINED as
164
+ // `shouldConfirmWithoutGrade({ committedFileCount, args })` — a looser pattern
165
+ // matches that signature and passes even with the call site deleted. Verified:
166
+ // with the guard stubbed to `if (false)`, the loose form stayed green.
167
+ // `committedFiles.length` exists only at the call site.
168
+ assert.match(src, /shouldConfirmWithoutGrade\(\{\s*committedFileCount:\s*committedFiles\.length/,
169
+ 'the re-grade empty-diff guard must CALL shouldConfirmWithoutGrade, not merely export it');
170
+ assert.match(src, /postTasksIdConfirm\(\{ id: taskId, body: \{ verified \} \}\)/,
171
+ 'and reaching it must land on the manual-confirm route a no-artifact task needs');
172
+ });
173
+
174
+ test('shouldConfirmWithoutGrade: defaults and odd inputs never throw', () => {
175
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }), false, 'no args → no declaration');
176
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }), true, 'a numeric string still counts as empty');
177
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }), false, 'an unknown count is not an empty one');
178
+ });
179
+
127
180
  console.log('');
128
181
  console.log(`ship_resume: ${passed} passed, ${failed} failed`);
129
182
  process.exit(failed === 0 ? 0 : 1);