@bongos/core 1.19.45 → 1.19.47

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.45",
6
- "core_contract": "1.19.45",
7
- "source_commit": "290f271a1fb465184f1e30a82938dbb9fc0edd7f",
5
+ "core_version": "1.19.47",
6
+ "core_contract": "1.19.47",
7
+ "source_commit": "db1b5f6d74e9840d6f87d3cf3c8c5a4b16f5a667",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T17:49:40.375Z",
9
+ "built_at": "2026-08-11T18:11:51.578Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 290,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1214,
14
+ "functional_verbatim": 1216,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1525,
20
- "tree_sha256": "0722aab68f68291e412986ff75bc5c9e53914d42248afd4dd8f3fddef07c1d82",
19
+ "file_count": 1527,
20
+ "tree_sha256": "7183ad3d761a851cbc20b435a3b193c60c242e90bf4808ea3d0c68bbb798a1d0",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1372,7 +1372,7 @@
1372
1372
  {
1373
1373
  "path": "docs/file-map.md",
1374
1374
  "mode": "0000644",
1375
- "sha256": "15801d1c51e7e46ccf4e52dad5d3d34e4adc96b72eed60e3ef2c9ccccf94d1da"
1375
+ "sha256": "01f8ff6aaa583d333b466f4d5cff849c09327653c33dcb3d82d60cc46f913cb8"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/handoff-template.md",
@@ -2672,7 +2672,7 @@
2672
2672
  {
2673
2673
  "path": "modules/discord/ship-broadcast.js",
2674
2674
  "mode": "0000644",
2675
- "sha256": "5c1273de955d5559e549603d7c60a6987f88696631c7536c429c04d55c2fdf5c"
2675
+ "sha256": "a6250569de6838b1769768491ed2c9bc1da30cd8cd0ebda697ea142df7d902b8"
2676
2676
  },
2677
2677
  {
2678
2678
  "path": "modules/discord/webhook.js",
@@ -2857,7 +2857,7 @@
2857
2857
  {
2858
2858
  "path": "modules/grading/grader-rubric.json",
2859
2859
  "mode": "0000644",
2860
- "sha256": "6917875f2be5c39248b44caf7bf8d50f68cbe8effbc2bfdef3ba11e5ac17fc6c"
2860
+ "sha256": "2e080d8d50383a24d77f9cf166f6e93e05e7bccc8775d14bbf68f2d6733eecd4"
2861
2861
  },
2862
2862
  {
2863
2863
  "path": "modules/grading/grader-workers/efficiency.js",
@@ -2912,7 +2912,7 @@
2912
2912
  {
2913
2913
  "path": "modules/grading/grader.js",
2914
2914
  "mode": "0000644",
2915
- "sha256": "8073955da873ca3fa779ef614368af52e9f0be93b800f0590f07d34fceca5545"
2915
+ "sha256": "f6b80df54bb7634a9effb1cbe51b3ff99b05b0b7912be5a06690f63d237689f9"
2916
2916
  },
2917
2917
  {
2918
2918
  "path": "modules/grading/module.json",
@@ -4032,12 +4032,12 @@
4032
4032
  {
4033
4033
  "path": "package-lock.json",
4034
4034
  "mode": "0000644",
4035
- "sha256": "b2fc08b1d386c093629982f52a456c2b79c9f3824a22a696ed6fdb37a9073def"
4035
+ "sha256": "c91ae553a7cd336d399c802db07c4aac4fb744633cf5c26a6da00a5c65b02a18"
4036
4036
  },
4037
4037
  {
4038
4038
  "path": "package.json",
4039
4039
  "mode": "0000644",
4040
- "sha256": "d955953100c62b9b03bec90b70122f0a35d18bed91e60af2ae78f0573509a852"
4040
+ "sha256": "cac25ff2006b5e47c3a22049068c6c155b3c9026860bda78a5c952996b050304"
4041
4041
  },
4042
4042
  {
4043
4043
  "path": "public-docs/index.html",
@@ -5227,7 +5227,7 @@
5227
5227
  {
5228
5228
  "path": "scripts/gds/ship.js",
5229
5229
  "mode": "0000644",
5230
- "sha256": "616d72a72f4603fe303434f3302b9a227d1dbb81215c5e35ee37133fb8ce06c0"
5230
+ "sha256": "94f343beba33727cb83367555c35a699f53c8efa5d59e3b65d3f6a1cf3e0e3b0"
5231
5231
  },
5232
5232
  {
5233
5233
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5657,7 +5657,7 @@
5657
5657
  {
5658
5658
  "path": "src/module-api.js",
5659
5659
  "mode": "0000644",
5660
- "sha256": "ef3fa14314f284d7e93062ddc0b9ee7a046885bb13b20e41f62c7ef66eb29a24"
5660
+ "sha256": "ac8f21f650da5da4f71f922b918e87bc5bfcf530c1b0b6210623bf7edd9433b0"
5661
5661
  },
5662
5662
  {
5663
5663
  "path": "src/module-loader/loader.js",
@@ -6637,7 +6637,7 @@
6637
6637
  {
6638
6638
  "path": "tests/grader.mjs",
6639
6639
  "mode": "0000644",
6640
- "sha256": "9c8525b85be898be30d6034902b34fcdedb16cf4b37fa8d79fbd5d1fb89898cb"
6640
+ "sha256": "227d71aaab363d9346c3034f41cfb518e866b49bb460802b32626f288ecdcd76"
6641
6641
  },
6642
6642
  {
6643
6643
  "path": "tests/grader_bypass.mjs",
@@ -6789,6 +6789,11 @@
6789
6789
  "mode": "0000644",
6790
6790
  "sha256": "7fa36152b6dbb17b2855d261ddbea96af63d26f97001273cd131104235ea431d"
6791
6791
  },
6792
+ {
6793
+ "path": "tests/linkify_check.mjs",
6794
+ "mode": "0000644",
6795
+ "sha256": "810683a5283bd5fd7ef66b4f183b4fe8f978b03e156736c5c7cb91fc4f906c99"
6796
+ },
6792
6797
  {
6793
6798
  "path": "tests/linkify_refs.mjs",
6794
6799
  "mode": "0000644",
@@ -7422,7 +7427,12 @@
7422
7427
  {
7423
7428
  "path": "tests/ship_broadcast_server.mjs",
7424
7429
  "mode": "0000644",
7425
- "sha256": "72507f8192bc95ac1c3a92714d2280a43ed588820ce409586a9dad88c5677b09"
7430
+ "sha256": "2bb81a2649402bc2c17b04d17a4d749ff674f1bf5ac101d168412e709bea8550"
7431
+ },
7432
+ {
7433
+ "path": "tests/ship_builder_model.mjs",
7434
+ "mode": "0000644",
7435
+ "sha256": "afdb09a3ee072fea82390a187c12c42d8249fce0ce84644a4b8dff6a643dd009"
7426
7436
  },
7427
7437
  {
7428
7438
  "path": "tests/ship_card.mjs",
package/docs/file-map.md CHANGED
@@ -157,7 +157,7 @@
157
157
  │ ├── dev.js ← `bongos dev`: one-command LOCAL launcher for a standalone instance (Postgres → createdb → migrate → server on the branding port → sign-in); up/stop/status (ADR 0149)
158
158
  │ ├── dev-lib.js ← pure + injectable core of `bongos dev` (branding→port/db/env resolution, managed detached server pid/health); unit-tested by tests/dev_lib.mjs (ADR 0149)
159
159
  │ ├── session-eval.js ← BFG session-inefficiency analysis engine (6D.3): classify turns, compute ceremony-vs-work, detect footguns, file systemic findings (ADR 0027)
160
- │ ├── linkify-refs.js ← rewrite bare `#NNN` task refs across ALL docs → status-dashboard permalinks (stops GitHub's PR/issue autolinker mis-linking them); conservative classifier, token-free id list via /public/ref-ids; --apply / --check (CI gate: .github/workflows/doc-ref-linkify.yml) / --dry-run default ([#604](https://example.com/builders#/task/604), [#653](https://example.com/builders#/task/653)).
160
+ │ ├── linkify-refs.js ← rewrite bare `#NNN` task refs across ALL docs → status-dashboard permalinks (stops GitHub's PR/issue autolinker mis-linking them); conservative classifier, token-free id list via /public/ref-ids; --apply / --check (CI gate: tests/linkify_check.mjs, in the required `unit` check) / --dry-run default ([#604](https://example.com/builders#/task/604), [#653](https://example.com/builders#/task/653)).
161
161
  │ ├── release.js ← resolve claim as abandoned (`/builder-release`)
162
162
  │ ├── cost.js ← log a cost entry (`/builder-cost`)
163
163
  │ ├── me.js ← current builder profile + leaderboard
@@ -87,9 +87,14 @@ async function postShipBroadcast(task, deps = {}) {
87
87
  // NOT start with `#<taskId>` — a LEADING task marker is what the applause bot maps
88
88
  // 👍 reactions onto (discord-reaction.parseShipMarker), and a strand is not
89
89
  // applaudable shipped work.
90
- function chooseStrandTargets({ securitySensitive, taskId, reason }) {
90
+ function chooseStrandTargets({ securitySensitive, taskId, title, reason }) {
91
91
  if (taskId == null) return [];
92
- const line = `⚠️ task ${taskId} stranded at 'confirmed' (${reason || 'unknown'}) — its builder must fix + re-land it, or release it.`;
92
+ // Title folds in so the notice is self-contained (task 1002638) — a builder
93
+ // shouldn't have to look the id up to know which work died. Degrades to the
94
+ // id-only line when the payload carries none. Titles are bounded (LIMITS.TITLE
95
+ // = 200) so the line stays well under Discord's message cap.
96
+ const what = title ? `task ${taskId} — "${title}"` : `task ${taskId}`;
97
+ const line = `⚠️ ${what} stranded at 'confirmed' (${reason || 'unknown'}) — its builder must fix + re-land it, or release it.`;
93
98
  if (securitySensitive) {
94
99
  return [{ channel: 'security_ship_feed', line }];
95
100
  }
@@ -110,7 +115,7 @@ async function postStrandNotice(info, deps = {}) {
110
115
 
111
116
  const postWebhook = deps.postWebhook || require('./webhook').postWebhook;
112
117
  const log = deps.log || console.log;
113
- const targets = chooseStrandTargets({ securitySensitive: sec, taskId: info.id, reason: info.reason });
118
+ const targets = chooseStrandTargets({ securitySensitive: sec, taskId: info.id, title: info.title, reason: info.reason });
114
119
  for (const { channel, line } of targets) {
115
120
  try {
116
121
  await postWebhook(channel, line);
@@ -8,8 +8,9 @@
8
8
  "grader_kind": "subagent-panel",
9
9
  "threshold": 6.0,
10
10
  "gate": "All workers in the active panel must independently report verdict='pass' for confirmed→credits. Any worker reporting 'fail' (or erroring out) keeps the task at completed. Any worker reporting 'concerns' (low confidence, partial AC, advisory economy below threshold, major non-blocker issue, etc.) falls through to the manual-confirm path (POST /tasks/:id/confirm) — same release-valve as v3 low-confidence. The 'concerns' verdict is forwarded to the server-side validateAndScore as confidence=fallthrough_confidence (0.3 by default, below LOW_CONFIDENCE=0.4) so the existing server gate aligns without protocol changes.",
11
- "default_model": "claude-sonnet-4-6",
12
- "assumed_builder_model": "claude-opus-4-7",
11
+ "default_model": "claude-sonnet-5",
12
+ "assumed_builder_model": "claude-fable-5",
13
+ "model_refresh_note": "task 1002659 (2026-08-11): pins refreshed to the Claude 5 family. Assumed builder = claude-fable-5 (the owner-top-model default per CLAUDE.md §2); default grader = claude-sonnet-5 (cross-model per ADR 0024, cost-parity with the prior sonnet-4-6 posture — opus-5 grades the sonnet/haiku builders where the grader should be the stronger model). selectGraderModel no longer throws on an unknown builder id: it warns loudly and falls back to default_model (the #117 throw stranded ships whose builder id lacked an opus/sonnet/haiku substring — exactly what claude-fable-5 is).",
13
14
  "active_panel": ["narc", "quality", "hacker", "efficiency"],
14
15
  "fallthrough_confidence": 0.3,
15
16
  "low_confidence_threshold": 0.4,
@@ -99,8 +99,8 @@ const LOW_CONFIDENCE = V4.low_confidence_threshold;
99
99
  // DEFAULT_SHOTS is kept for the v3 multi-shot path (still exported for
100
100
  // diagnostics); v4 doesn't use shots — it uses workers.
101
101
  const DEFAULT_SHOTS = V3.shots || 3;
102
- const ASSUMED_BUILDER_MODEL = V4.assumed_builder_model || 'claude-opus-4-7';
103
- const DEFAULT_GRADER_MODEL = V4.default_model || 'claude-sonnet-4-6';
102
+ const ASSUMED_BUILDER_MODEL = V4.assumed_builder_model || 'claude-fable-5';
103
+ const DEFAULT_GRADER_MODEL = V4.default_model || 'claude-sonnet-5';
104
104
  const FALLTHROUGH_CONFIDENCE = V4.fallthrough_confidence ?? 0.3;
105
105
  const workers = require('./grader-workers');
106
106
  // Carved into modules/grading/ (BV1.R73 / ADR 0091 §1+§4): the two deterministic
@@ -208,27 +208,31 @@ function isTrivialDiff({ changedFiles, diffStats } = {}) {
208
208
 
209
209
  // ---------- v3: cross-family model selection (ADR 0024) ----------
210
210
 
211
- // Returns the grader model given the builder's model — a DIFFERENT family from
212
- // the builder is the cross-family adversarial guarantee. With no builderModel
213
- // the caller is assuming the default Opus builder (CLAUDE.md §2), so it resolves
214
- // through the opus branch to Sonnet — that is the legitimate default path the
215
- // live callers (selectGraderModel({})) take.
211
+ // Returns the grader model given the builder's model — a DIFFERENT model from
212
+ // the builder is the cross-model adversarial guarantee (ADR 0024). With no
213
+ // builderModel the caller assumes the owner-top-model builder (CLAUDE.md §2),
214
+ // resolving to the default grader — the path selectGraderModel({}) takes.
216
215
  //
217
- // #117: an EXPLICITLY-supplied model we don't recognize is a misconfiguration,
218
- // not a default. We used to silently fall back to Sonnet, which would mask a
219
- // future model family (one whose id lacks 'opus'/'sonnet'/'haiku') and quietly
220
- // grade it on the wrong cross-family assumption. Throw loudly instead so the
221
- // caller fixes the input rather than getting a silently-wrong grader model.
216
+ // task 1002659 (Claude 5 refresh): top-tier builders (fable/mythos, opus)
217
+ // grade on the current mid tier; sonnet/haiku builders grade on the tier
218
+ // above. #117's throw-on-unknown is reversed: the throw fired INSIDE the
219
+ // grade step, stranding any ship whose builder id lacked an opus/sonnet/haiku
220
+ // substring (claude-fable-5 was exactly that) — a worse failure than the
221
+ // misconfiguration it guarded. Unknown ids now warn loudly and grade on
222
+ // DEFAULT_GRADER_MODEL; the id still reaches signals.builder_model_assumed,
223
+ // so /grader-health can spot the drift.
222
224
  function selectGraderModel({ builderModel } = {}) {
223
225
  const bm = (builderModel || ASSUMED_BUILDER_MODEL).toLowerCase();
224
- if (bm.includes('opus')) return 'claude-sonnet-4-6';
225
- if (bm.includes('sonnet')) return 'claude-opus-4-7';
226
- if (bm.includes('haiku')) return 'claude-opus-4-7';
227
- throw new Error(
226
+ if (bm.includes('fable') || bm.includes('mythos')) return 'claude-sonnet-5';
227
+ if (bm.includes('opus')) return 'claude-sonnet-5';
228
+ if (bm.includes('sonnet')) return 'claude-opus-5';
229
+ if (bm.includes('haiku')) return 'claude-opus-5';
230
+ console.error(
228
231
  `selectGraderModel: unrecognized builder model ${JSON.stringify(builderModel)} — ` +
229
- `expected an id containing 'opus', 'sonnet', or 'haiku'. Refusing to silently ` +
230
- `fall back to a default grader model (#117).`
232
+ `no 'fable'/'mythos'/'opus'/'sonnet'/'haiku' token; grading on the default ` +
233
+ `${DEFAULT_GRADER_MODEL} (cross-model unverifiable — task 1002659, was a #117 throw)`
231
234
  );
235
+ return DEFAULT_GRADER_MODEL;
232
236
  }
233
237
 
234
238
  // ---------- v3: prompt builder (default-fail framing) ----------
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.45",
3
+ "version": "1.19.47",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.45",
9
+ "version": "1.19.47",
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.45",
3
+ "version": "1.19.47",
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",
@@ -251,6 +251,39 @@ function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
251
251
  return null;
252
252
  }
253
253
 
254
+ // resolveBuilderModel — the REAL model that authored this session's work, read
255
+ // from the session transcript (dominant by output tokens, synthetic turns
256
+ // skipped), so selectGraderModel gets a true cross-model input instead of the
257
+ // ASSUMED_BUILDER_MODEL fallback (task 1002659 — everything graded on the
258
+ // assumed-builder default before this). Best-effort: no session id, no
259
+ // transcript, or any read error → null → the grader assumes, as before.
260
+ function resolveBuilderModel(opts = {}) {
261
+ try {
262
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
263
+ // Same path-traversal guard as uploadSessionOnShip: the id lands in a path.
264
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
265
+ const transcriptPath = resolveTranscriptPath(sessionId, [process.cwd(), REPO_ROOT].filter(Boolean), opts);
266
+ if (!transcriptPath) return null;
267
+ const perModel = new Map();
268
+ for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
269
+ if (!line.trim()) continue;
270
+ let o;
271
+ try { o = JSON.parse(line); } catch (_) { continue; }
272
+ const msg = o && o.message;
273
+ if (!msg || !msg.model || msg.model === '<synthetic>' || !msg.usage) continue;
274
+ perModel.set(msg.model, (perModel.get(msg.model) || 0) + (Number(msg.usage.output_tokens) || 0));
275
+ }
276
+ let best = null;
277
+ let bestTokens = 0;
278
+ for (const [model, tokens] of perModel) {
279
+ if (tokens > bestTokens) { best = model; bestTokens = tokens; }
280
+ }
281
+ return best;
282
+ } catch (_) {
283
+ return null;
284
+ }
285
+ }
286
+
254
287
  function resolveMemoryDir() {
255
288
  // REPO_ROOT is the main worktree (canonical project checkout). The memory dir
256
289
  // is keyed to that path, not the per-task feature worktree we're shipping from.
@@ -1812,11 +1845,10 @@ function mergeMainIntoBranch() {
1812
1845
 
1813
1846
  // ---------- Pre-verify: auto-linkify the ship's own changed docs ----------
1814
1847
  //
1815
- // The doc-ref-linkify CI gate (.github/workflows/doc-ref-linkify.yml) runs on
1816
- // push:main — i.e. AFTER ship's auto-merge has already landed the commit. So a
1817
- // single bare "#NNN" task ref that slips into a doc red-Xes the build and
1818
- // emails a failure for an already-merged commit. Caught post-merge, the gate is
1819
- // pure noise: it can't prevent the leak, only complain about it (#747).
1848
+ // The doc-ref-linkify CI gate (tests/linkify_check.mjs, in the required `unit`
1849
+ // check — task 1002639; a standalone doc-ref-linkify.yml workflow never
1850
+ // actually existed) red-Xes a PR carrying a bare "#NNN" task ref in a doc. A
1851
+ // red-X after the builder walked away still strands the land (#747).
1820
1852
  //
1821
1853
  // The structural cure is to fix the ship's OWN changed docs here, before the
1822
1854
  // merge, and commit the rewrite. The gate then stays as the backstop for refs
@@ -2062,6 +2094,9 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
2062
2094
  timeoutMs: GRADER_TIMEOUT_MS,
2063
2095
  cwd: process.cwd(), // grade the FEATURE worktree, not REPO_ROOT
2064
2096
  };
2097
+ // task 1002659: the real session model (transcript-derived) drives the
2098
+ // cross-model grader pick; null → the grader assumes, as before.
2099
+ const builderModel = resolveBuilderModel();
2065
2100
  const t0 = Date.now();
2066
2101
  let result;
2067
2102
  // #513: a small copy/docs-only diff gets a SINGLE generalist grader shot
@@ -2071,7 +2106,7 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
2071
2106
  // inefficiency of running a 4-worker panel on a +1/-1 copy tweak.
2072
2107
  if (grader.isTrivialDiff({ changedFiles, diffStats: shortstat })) {
2073
2108
  const totalLines = (shortstat.lines_added || 0) + (shortstat.lines_removed || 0);
2074
- console.log(` subagent grader ${grader.VERSION}: TRIVIAL diff (${shortstat.files_changed} file(s), ${totalLines} line(s), copy/docs only) → single generalist shot, model=${grader.selectGraderModel({})} — instead of the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel (#513)`);
2109
+ console.log(` subagent grader ${grader.VERSION}: TRIVIAL diff (${shortstat.files_changed} file(s), ${totalLines} line(s), copy/docs only) → single generalist shot, model=${grader.selectGraderModel({ builderModel })} — instead of the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel (#513)`);
2075
2110
  console.log(` gate: the shot must return functional_fidelity>=${grader.THRESHOLD} AND code_quality>=${grader.THRESHOLD} AND confidence>=0.4`);
2076
2111
  result = await grader.gradeSubagentV3MultiShot({
2077
2112
  task,
@@ -2080,11 +2115,12 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
2080
2115
  changedFiles,
2081
2116
  diffStats: shortstat,
2082
2117
  shots: 1,
2118
+ builderModel, // task 1002659: real session model → cross-model grader pick
2083
2119
  runOpts,
2084
2120
  });
2085
2121
  } else {
2086
2122
  const panel = grader.WORKERS.DEFAULT_PANEL;
2087
- console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.DEFAULT_GRADER_MODEL} (assumed builder ${grader.ASSUMED_BUILDER_MODEL}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
2123
+ console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
2088
2124
  console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
2089
2125
  if (priorRound) {
2090
2126
  console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
@@ -2096,6 +2132,7 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
2096
2132
  changedFiles,
2097
2133
  diffStats: shortstat, // V3.R92 #353: feed diff size into the economy axis
2098
2134
  builderRank, // SEC #863: drives the permission-path pre-pass
2135
+ builderModel, // task 1002659: real session model → cross-model grader pick
2099
2136
  priorRound, // task 1002662: delta-aware re-grade context (panel path only)
2100
2137
  runOpts,
2101
2138
  });
@@ -3647,8 +3684,8 @@ async function shipMain() {
3647
3684
 
3648
3685
  // ---- Pre-verify: auto-linkify this ship's own changed docs (#747) ----
3649
3686
  // Fix bare "#NNN" task refs in the docs this ship changed BEFORE the merge,
3650
- // so they never reach main and red-X the post-merge doc-ref-linkify gate.
3651
- // Best-effort.
3687
+ // so they never red-X the doc-ref-linkify gate (tests/linkify_check.mjs in
3688
+ // the `unit` check). Best-effort.
3652
3689
  autoLinkifyChangedDocs(ac);
3653
3690
 
3654
3691
  // ---- Empty-diff / phantom-ship guard (must run BEFORE the review gate + resolve) ----
@@ -4221,6 +4258,9 @@ async function regradeMain() {
4221
4258
  // exported: it does I/O + network and is covered by the live smoke at ship.)
4222
4259
  module.exports = {
4223
4260
  scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath,
4261
+ // task 1002659: transcript-derived real builder model (dominant by output
4262
+ // tokens) — exported pure-ish (fs read only, opts-injectable) for the test.
4263
+ resolveBuilderModel,
4224
4264
  // task 1002471: the project-path encoder, exported pure so the win32 shapes
4225
4265
  // (drive colon, backslash separators, spaces) are pinned cross-platform —
4226
4266
  // resolveTranscriptPath itself runs path.resolve, which can't carry a Windows
package/src/module-api.js CHANGED
@@ -517,8 +517,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
517
517
  // landed since 1.19.43 with no explicit bump. run 31518312009. (task 1002620)
518
518
  // 1.19.45 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
519
519
  // landed since 1.19.44 with no explicit bump. run 31519500533. (task 1002620)
520
+ // 1.19.46 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
521
+ // landed since 1.19.45 with no explicit bump. run 31520432748. (task 1002620)
522
+ // 1.19.47 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
523
+ // landed since 1.19.46 with no explicit bump. run 31521404176. (task 1002620)
520
524
  // ---------------------------------------------------------------------------
521
- const CORE_VERSION = '1.19.45'; // CI auto-patch carrier (ADR 0161); see changelog above
525
+ const CORE_VERSION = '1.19.47'; // CI auto-patch carrier (ADR 0161); see changelog above
522
526
 
523
527
  // A namespaced logger so a module's log lines are attributable + consistent.
524
528
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/grader.mjs CHANGED
@@ -192,8 +192,8 @@ test('v4 exports the expected surface (current panel-based gate)', () => {
192
192
  assert.ok(grader.THRESHOLD >= 5.0 && grader.THRESHOLD <= 10.0);
193
193
  assert.ok(grader.VERSION.startsWith('v4'), `expected v4.*, got ${grader.VERSION}`);
194
194
  assert.equal(grader.DEFAULT_SHOTS, 3); // kept for v3 multi-shot diagnostic path
195
- assert.equal(grader.DEFAULT_GRADER_MODEL, 'claude-sonnet-4-6');
196
- assert.equal(grader.ASSUMED_BUILDER_MODEL, 'claude-opus-4-7');
195
+ assert.equal(grader.DEFAULT_GRADER_MODEL, 'claude-sonnet-5'); // task 1002659: Claude 5 family pin
196
+ assert.equal(grader.ASSUMED_BUILDER_MODEL, 'claude-fable-5');
197
197
  assert.equal(grader.FALLTHROUGH_CONFIDENCE, 0.3);
198
198
  // Worker registry sanity — Phase 2.2 added efficiency as the 4th worker
199
199
  // (after Phase 2.1's hacker).
@@ -819,32 +819,36 @@ test('v2 extractAssistantResult: passes-through non-envelope output', () => {
819
819
  // v3 — cross-family selection + multi-shot aggregation (MAS Phase 1 / #395)
820
820
  // ========================================================================
821
821
 
822
- test('v3 selectGraderModel: builder Opus → grader Sonnet (cross-family)', () => {
823
- assert.equal(grader.selectGraderModel({ builderModel: 'claude-opus-4-7' }), 'claude-sonnet-4-6');
824
- assert.equal(grader.selectGraderModel({ builderModel: 'claude-opus-4-8' }), 'claude-sonnet-4-6');
825
- assert.equal(grader.selectGraderModel({ builderModel: 'opus' }), 'claude-sonnet-4-6');
822
+ test('selectGraderModel: builder Fable/Mythos → grader Sonnet 5 (cross-model, task 1002659)', () => {
823
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-fable-5' }), 'claude-sonnet-5');
824
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-mythos-5' }), 'claude-sonnet-5');
826
825
  });
827
826
 
828
- test('v3 selectGraderModel: builder Sonnet → grader Opus (cross-family)', () => {
829
- assert.equal(grader.selectGraderModel({ builderModel: 'claude-sonnet-4-6' }), 'claude-opus-4-7');
830
- assert.equal(grader.selectGraderModel({ builderModel: 'sonnet' }), 'claude-opus-4-7');
827
+ test('selectGraderModel: builder Opus → grader Sonnet (cross-family)', () => {
828
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-opus-5' }), 'claude-sonnet-5');
829
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-opus-4-8' }), 'claude-sonnet-5');
830
+ assert.equal(grader.selectGraderModel({ builderModel: 'opus' }), 'claude-sonnet-5');
831
831
  });
832
832
 
833
- test('v3 selectGraderModel: builder Haiku → grader Opus', () => {
834
- assert.equal(grader.selectGraderModel({ builderModel: 'claude-haiku-4-5-20251001' }), 'claude-opus-4-7');
835
- assert.equal(grader.selectGraderModel({ builderModel: 'haiku' }), 'claude-opus-4-7');
833
+ test('selectGraderModel: builder Sonnet → grader Opus (cross-family)', () => {
834
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-sonnet-5' }), 'claude-opus-5');
835
+ assert.equal(grader.selectGraderModel({ builderModel: 'sonnet' }), 'claude-opus-5');
836
836
  });
837
837
 
838
- test('selectGraderModel: missing builder → default Sonnet (assumed-Opus builder); explicit unknown THROWS (#117)', () => {
839
- // No builderModel is the legitimate default path (the caller assumes the Opus
840
- // builder per CLAUDE.md §2), so it resolves through the opus branch to Sonnet.
841
- // This is the path the live callers — selectGraderModel({}) — take.
842
- assert.equal(grader.selectGraderModel({}), 'claude-sonnet-4-6');
843
- assert.equal(grader.selectGraderModel(), 'claude-sonnet-4-6');
844
- // An explicitly-supplied unrecognized model is a misconfiguration: loud-throw
845
- // instead of silently grading on the wrong cross-family assumption (#117).
846
- assert.throws(() => grader.selectGraderModel({ builderModel: 'gpt-4' }), /unrecognized builder model/);
847
- assert.throws(() => grader.selectGraderModel({ builderModel: 'llama-3' }), /unrecognized builder model/);
838
+ test('selectGraderModel: builder Haiku → grader Opus', () => {
839
+ assert.equal(grader.selectGraderModel({ builderModel: 'claude-haiku-4-5-20251001' }), 'claude-opus-5');
840
+ assert.equal(grader.selectGraderModel({ builderModel: 'haiku' }), 'claude-opus-5');
841
+ });
842
+
843
+ test('selectGraderModel: missing builder → default (assumed-Fable builder → Sonnet 5); unknown id FALLS BACK, never throws (task 1002659)', () => {
844
+ // No builderModel is the legitimate default path (the caller assumes the
845
+ // owner-top-model builder per CLAUDE.md §2), resolving to the default grader.
846
+ assert.equal(grader.selectGraderModel({}), 'claude-sonnet-5');
847
+ assert.equal(grader.selectGraderModel(), 'claude-sonnet-5');
848
+ // task 1002659 reverses #117's throw: an unknown id used to strand the ship
849
+ // inside the grade step. Now it warns loudly and grades on the default model.
850
+ assert.equal(grader.selectGraderModel({ builderModel: 'gpt-4' }), grader.DEFAULT_GRADER_MODEL);
851
+ assert.equal(grader.selectGraderModel({ builderModel: 'llama-3' }), grader.DEFAULT_GRADER_MODEL);
848
852
  });
849
853
 
850
854
  // --- aggregateShots: helpers ---
@@ -0,0 +1,39 @@
1
+ // tests/linkify_check.mjs — the doc-ref linkify CI gate (task 1002639).
2
+ //
3
+ // docs/file-map.md long claimed a `.github/workflows/doc-ref-linkify.yml` CI
4
+ // gate that never existed (the G-20 phantom-reference shape). This test IS the
5
+ // gate, made real the cheap way: it runs inside the required `unit` check, so a
6
+ // committed doc with a bare `#NNN` ref (which GitHub would mis-autolink to its
7
+ // own issues) fails the PR. linkify-refs.js --check already fails OPEN when the
8
+ // /public/ref-ids allow-list is unreachable (a network blip must not red-X an
9
+ // unrelated build); the spawn timeout below mirrors that posture — infra
10
+ // slowness is a warn + skip, never a false red.
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { spawnSync } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const SCRIPT = path.join(REPO, 'scripts', 'gds', 'linkify-refs.js');
19
+ const SPAWN_TIMEOUT_MS = 60_000; // under run-unit-tests' 90s so WE decide the timeout outcome
20
+
21
+ const r = spawnSync(process.execPath, [SCRIPT, '--check'], {
22
+ cwd: REPO,
23
+ encoding: 'utf8',
24
+ timeout: SPAWN_TIMEOUT_MS,
25
+ maxBuffer: 8 * 1024 * 1024,
26
+ });
27
+
28
+ if (r.error && r.error.code === 'ETIMEDOUT') {
29
+ console.warn(`linkify_check: --check did not finish in ${SPAWN_TIMEOUT_MS}ms — skipping (fail-open on infra, matching the script's own --check posture).`);
30
+ process.exit(0);
31
+ }
32
+
33
+ assert.equal(r.error, undefined, `linkify-refs.js --check failed to spawn: ${r.error && r.error.message}`);
34
+ assert.equal(
35
+ r.status,
36
+ 0,
37
+ `linkify-refs.js --check found bare/upgradeable refs in committed docs — run \`node scripts/gds/linkify-refs.js --apply\` and commit.\n${r.stdout}\n${r.stderr}`
38
+ );
39
+ console.log('linkify_check: committed docs carry no bare #NNN refs (linkify-refs.js --check exit 0).');
@@ -194,15 +194,36 @@ test('1002606 chooseStrandTargets: security strand → ONLY #security-ship-feed
194
194
  for (const { channel } of t) assert.ok(!PUBLIC_CHANNELS.has(channel));
195
195
  });
196
196
 
197
- await atest('1002606 postStrandNotice: posts to #ship-feed for a plain strand', async () => {
197
+ test('1002638 chooseStrandTargets: title folds into the line so the notice is self-contained', () => {
198
+ const t = shipBroadcast.chooseStrandTargets({
199
+ securitySensitive: false, taskId: 88, title: 'Fix the widget frobnicator', reason: 'strand:pr_closed_unmerged',
200
+ });
201
+ assert.equal(t.length, 1);
202
+ assert.match(t[0].line, /task 88/);
203
+ assert.match(t[0].line, /Fix the widget frobnicator/);
204
+ assert.match(t[0].line, /strand:pr_closed_unmerged/);
205
+ assert.ok(!/^\s*#\d/.test(t[0].line), 'strand line must not start with a #<id> ship marker');
206
+ });
207
+
208
+ test('1002638 chooseStrandTargets: missing title degrades to the id-only line (no "undefined")', () => {
209
+ for (const title of [undefined, null, '']) {
210
+ const t = shipBroadcast.chooseStrandTargets({ securitySensitive: false, taskId: 88, title, reason: 'strand:x' });
211
+ assert.equal(t.length, 1);
212
+ assert.match(t[0].line, /task 88/);
213
+ assert.ok(!/undefined|null/.test(t[0].line), `title=${String(title)} must not leak into the line`);
214
+ }
215
+ });
216
+
217
+ await atest('1002606 postStrandNotice: posts to #ship-feed for a plain strand (title carried through — 1002638)', async () => {
198
218
  const { fn, calls } = fakePoster();
199
219
  const res = await shipBroadcast.postStrandNotice(
200
- { id: 88, builder_id: 7, security_sensitive: false, reason: 'strand:pr_closed_unmerged' },
220
+ { id: 88, builder_id: 7, title: 'Fix the widget frobnicator', security_sensitive: false, reason: 'strand:pr_closed_unmerged' },
201
221
  { postWebhook: fn, log: quietLog }
202
222
  );
203
223
  assert.deepEqual(res.posted, ['ship_feed']);
204
224
  assert.equal(calls.length, 1);
205
225
  assert.match(calls[0].line, /task 88/);
226
+ assert.match(calls[0].line, /Fix the widget frobnicator/);
206
227
  });
207
228
 
208
229
  await atest('1002606 postStrandNotice: indeterminate security flag → FAIL CLOSED (no post)', async () => {
@@ -0,0 +1,76 @@
1
+ // tests/ship_builder_model.mjs — task 1002659: resolveBuilderModel reads the
2
+ // session transcript and returns the dominant model by output tokens, so the
3
+ // grader's cross-model pick keys on what actually authored the work.
4
+ import assert from 'node:assert';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const ship = require('../scripts/gds/ship.js');
12
+
13
+ let passed = 0;
14
+ let failed = 0;
15
+ function test(name, fn) {
16
+ try {
17
+ fn();
18
+ console.log(` ok ${name}`);
19
+ passed++;
20
+ } catch (e) {
21
+ console.error(`FAIL ${name}\n ${e.message}`);
22
+ failed++;
23
+ }
24
+ }
25
+
26
+ // Mirror ship.js's transcript layout: <base>/projects/<encoded cwd>/<sessionId>.jsonl
27
+ function writeTranscript(base, sessionId, lines) {
28
+ const encoded = ship.encodeProjectPath(path.resolve(process.cwd()));
29
+ const dir = path.join(base, 'projects', encoded);
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ const p = path.join(dir, `${sessionId}.jsonl`);
32
+ fs.writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'));
33
+ return p;
34
+ }
35
+
36
+ const msg = (model, outputTokens) => ({
37
+ message: { model, usage: { output_tokens: outputTokens } },
38
+ });
39
+
40
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-builder-model-'));
41
+ const SESSION = 'test-session-1002659';
42
+ const prevSession = process.env.CLAUDE_CODE_SESSION_ID;
43
+ process.env.CLAUDE_CODE_SESSION_ID = SESSION;
44
+
45
+ writeTranscript(base, SESSION, [
46
+ msg('claude-fable-5', 900),
47
+ msg('claude-haiku-4-5-20251001', 50),
48
+ msg('claude-fable-5', 800),
49
+ { message: { model: '<synthetic>', usage: { output_tokens: 99999 } } }, // skipped
50
+ { not_a_message: true },
51
+ ]);
52
+
53
+ test('dominant-by-output-tokens model wins; synthetic turns are skipped', () => {
54
+ assert.equal(ship.resolveBuilderModel({ base }), 'claude-fable-5');
55
+ });
56
+
57
+ test('no transcript for the session → null (grader assumes)', () => {
58
+ process.env.CLAUDE_CODE_SESSION_ID = 'no-such-session';
59
+ assert.equal(ship.resolveBuilderModel({ base }), null);
60
+ process.env.CLAUDE_CODE_SESSION_ID = SESSION;
61
+ });
62
+
63
+ test('missing or path-hostile session id → null', () => {
64
+ delete process.env.CLAUDE_CODE_SESSION_ID;
65
+ assert.equal(ship.resolveBuilderModel({ base }), null);
66
+ process.env.CLAUDE_CODE_SESSION_ID = '../evil';
67
+ assert.equal(ship.resolveBuilderModel({ base }), null);
68
+ process.env.CLAUDE_CODE_SESSION_ID = SESSION;
69
+ });
70
+
71
+ if (prevSession === undefined) delete process.env.CLAUDE_CODE_SESSION_ID;
72
+ else process.env.CLAUDE_CODE_SESSION_ID = prevSession;
73
+ fs.rmSync(base, { recursive: true, force: true });
74
+
75
+ console.log(`\nship_builder_model: ${passed} passed, ${failed} failed`);
76
+ process.exit(failed ? 1 : 0);