@bongos/core 1.19.889 → 1.19.891

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.
@@ -110,7 +110,27 @@ function describeOutage(gating) {
110
110
  }
111
111
 
112
112
  function aggregatePanel(workerResults, meta = {}) {
113
- const N = workerResults.length;
113
+ // task 1003977 — THE NON-GATING PARTITION, before any gate arithmetic.
114
+ //
115
+ // A non-gating worker (today: the Reader, the advisory copy lens) contributes
116
+ // its scores and issues to the RECORD and nothing at all to the GATE. It is
117
+ // removed here, once, rather than special-cased in each of the counts below —
118
+ // pass/concerns/fail counts, the error and outage detection, the confidence
119
+ // median and every branch of the verdict all read `gateResults`.
120
+ //
121
+ // Removing it from the counts is why a Reader outage cannot reach #943's
122
+ // "every gating worker is missing signal" branch, and why a Reader cannot
123
+ // nudge the confidence median that drives the server-side gate. `N` stays the
124
+ // GATE's denominator so the verdict strings keep meaning what they said
125
+ // before ("2/4 gating worker(s) failed" counts the four that can fail).
126
+ //
127
+ // ADVISORY_WORKERS below is a DIFFERENT, weaker thing: narc is advisory in
128
+ // that a narc-only fail downgrades to manual-confirm. A non-gating worker
129
+ // never reaches even that.
130
+ const isNonGating = (w) => w && workers.isNonGatingKind(w.kind);
131
+ const nonGatingResults = workerResults.filter(isNonGating);
132
+ const gateResults = workerResults.filter((w) => !isNonGating(w));
133
+ const N = gateResults.length;
114
134
 
115
135
  // Score axes — median across workers that reported each. Reuses the
116
136
  // module-level median() helper (rounded to one decimal).
@@ -124,6 +144,12 @@ function aggregatePanel(workerResults, meta = {}) {
124
144
  // Phase 2.2 (#421): Efficiency Monger reports efficiency_score. Same
125
145
  // additive treatment as security_score.
126
146
  const efficiencyScore = median(workerResults.map((w) => w.scores?.efficiency_score));
147
+ // task 1003977: the Reader reports copy_score. Recorded like security_score
148
+ // and efficiency_score before it — additive, and read from the FULL result
149
+ // list rather than gateResults, because the whole point is that a non-gating
150
+ // worker's SIGNAL lands in the record even though its verdict never reaches
151
+ // the gate. Null whenever no Reader ran, which is every non-copy-bearing ship.
152
+ const copyScore = median(workerResults.map((w) => w.scores?.copy_score));
127
153
 
128
154
  const economyPenalty =
129
155
  economy != null ? Math.max(0, (THRESHOLD - economy) / 2) : 0;
@@ -131,8 +157,8 @@ function aggregatePanel(workerResults, meta = {}) {
131
157
  const blended = ff != null && cq != null ? r1((ff + cq) / 2) : 0;
132
158
 
133
159
  // Verdict counts.
134
- const passCount = workerResults.filter((w) => w.verdict === 'pass').length;
135
- const concernsCount = workerResults.filter((w) => w.verdict === 'concerns').length;
160
+ const passCount = gateResults.filter((w) => w.verdict === 'pass').length;
161
+ const concernsCount = gateResults.filter((w) => w.verdict === 'concerns').length;
136
162
  // A crashed worker sets verdict='fail' as a DEFAULT in its computeVerdict()
137
163
  // subprocess_error / parse_error / schema_error early returns (see each
138
164
  // worker module) — that is MISSING SIGNAL, not a substantive finding, and
@@ -149,12 +175,12 @@ function aggregatePanel(workerResults, meta = {}) {
149
175
  const hasSubstantiveIssue = (w) =>
150
176
  (w.issues || []).some((i) => i.severity === 'blocker' || i.severity === 'major');
151
177
  const isMissingSignal = (w) => !!w.error && !hasSubstantiveIssue(w);
152
- const failCount = workerResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
178
+ const failCount = gateResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
153
179
  // NOTE (#473/#589): signals.error_count counts only isMissingSignal workers — i.e. a worker that
154
180
  // errored AND surfaced no blocking issue. A worker that errored but still raised a blocker/major
155
181
  // is a real finding, so it counts in fail_count (above), NOT here. This is a deliberate semantic
156
182
  // change from the old "all w.error" meaning; keep error_count = missing-signal-only.
157
- const errorCount = workerResults.filter((w) => isMissingSignal(w)).length;
183
+ const errorCount = gateResults.filter((w) => isMissingSignal(w)).length;
158
184
 
159
185
  // V3.R85 (#481): the Narc (functional_fidelity) is ADVISORY in the gate —
160
186
  // Lars's call 2026-05-30 after #303, where over-literal acceptance-criteria
@@ -188,8 +214,8 @@ function aggregatePanel(workerResults, meta = {}) {
188
214
  // taken out by a grader outage, so it is not evidence either way about whether the
189
215
  // panel ran. Task 1003574.
190
216
  const isDeterministic = (w) => w && w.model === 'deterministic';
191
- const nonAdvisory = workerResults.filter((w) => !isAdvisory(w));
192
- const gating = nonAdvisory.length ? nonAdvisory : workerResults;
217
+ const nonAdvisory = gateResults.filter((w) => !isAdvisory(w));
218
+ const gating = nonAdvisory.length ? nonAdvisory : gateResults;
193
219
  const gatingFail = gating.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
194
220
  const gatingError = gating.filter((w) => isMissingSignal(w)).length;
195
221
  const gatingConcerns = gating.filter((w) => w.verdict === 'concerns').length;
@@ -228,15 +254,15 @@ function aggregatePanel(workerResults, meta = {}) {
228
254
  const gatingErrorAll = subagentGating.length > 0 && subagentGating.every((w) => isMissingSignal(w));
229
255
  // Advisory-worker outcomes — never a hard block. Uses isAdvisory so a Narc
230
256
  // that DID raise a blocker is treated as gating (in `nonAdvisory`), not here.
231
- const advisoryFail = workerResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
232
- const advisoryConcerns = workerResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
257
+ const advisoryFail = gateResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
258
+ const advisoryConcerns = gateResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
233
259
 
234
260
  // Confidence — median for pass, fallthrough for concerns, 0 for fail.
235
261
  // (Drives the server-side gate; see file header.) Computed UNROUNDED —
236
262
  // confidence sits near the 0.4 gate cap so the module-level median()
237
263
  // helper (which rounds via r1()) would risk nudging a borderline value
238
264
  // across the boundary. Inlined for that one-decimal-precision reason.
239
- const confVals = workerResults
265
+ const confVals = gateResults
240
266
  .map((w) => w.confidence)
241
267
  .filter((n) => Number.isFinite(n))
242
268
  .slice()
@@ -360,7 +386,12 @@ function aggregatePanel(workerResults, meta = {}) {
360
386
 
361
387
  // Build display notes.
362
388
  const lines = [];
363
- lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${workerResults.map((w) => w.kind).join(', ')}) — ${verdict}`);
389
+ // N counts the GATING workers; a non-gating one is named separately so the
390
+ // line cannot read as an off-by-one ("panel of 4: …five kinds…").
391
+ const advisoryKinds = nonGatingResults.map((w) => w.kind);
392
+ lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${gateResults.map((w) => w.kind).join(', ')}`
393
+ + (advisoryKinds.length ? ` · advisory, non-gating: ${advisoryKinds.join(', ')}` : '')
394
+ + `) — ${verdict}`);
364
395
  if (ff != null) lines.push(` functional_fidelity (Narc): ${ff}/10`);
365
396
  if (cq != null) lines.push(` code_quality (Quality): ${cq}/10`);
366
397
  if (economy != null) lines.push(` economy (Quality, adv.): ${economy}/10`);
@@ -421,6 +452,7 @@ function aggregatePanel(workerResults, meta = {}) {
421
452
  code_quality_effective: cqEffective,
422
453
  security_score: security,
423
454
  efficiency_score: efficiencyScore,
455
+ copy_score: copyScore,
424
456
  },
425
457
  signals: {
426
458
  grader_kind: 'subagent-panel',
@@ -470,6 +502,10 @@ function graderUnavailableResult({ reason, note, meta = {} } = {}) {
470
502
  code_quality_effective: null,
471
503
  security_score: null,
472
504
  efficiency_score: null,
505
+ // task 1003977: nulled with the rest so an outage grade's rubric_scores
506
+ // has the SAME SHAPE as a normal one. A key that is absent here and
507
+ // present there is a difference a consumer has to know about.
508
+ copy_score: null,
473
509
  },
474
510
  signals: {
475
511
  grader_kind: 'subagent-panel',
@@ -135,23 +135,33 @@ function hasCopyOrVisualWork(task) {
135
135
  // STEP 2 remakes the copy/visuals with both routes explicitly offered — R04's
136
136
  // brief, verbatim in intent.
137
137
  //
138
- // STEP 1 used to say "coach the grader", and for as long as that was the wording
139
- // it asked for something no artist could do (task 1003937, ADR 0298). The panel's
140
- // four lenses — narc, quality, hacker, efficiency — read completion, code quality,
141
- // security and runtime cost; not one of them reads a sentence, so there was no
142
- // counterpart to coach and four consecutive reviews found the same wall. The
143
- // ADR's decision is a fifth ADVISORY 'reader' lens, scoped to copy-bearing diffs
144
- // by this file's own hasCopyOrVisualWork, built only after the instance writes a
145
- // voice guide to judge against. Until it exists the step asks for the RECORD
146
- // instead of the coaching: followable today, and it is the corpus both the guide
147
- // and the lens will be written from. When the reader lands, this reverts to
148
- // coaching a lens that is actually listening — and the wording is the signal for
149
- // which of those two worlds we are in, so do not restore the old sentence early.
138
+ // STEP 1 ASKS FOR COACHING AGAIN, because there is now a lens listening.
139
+ //
140
+ // The history, because the wording is load-bearing: STEP 1 originally said
141
+ // "coach the grader", which asked for something no artist could do (task
142
+ // 1003937, ADR 0298) — the panel's four lenses read completion, code quality,
143
+ // security and runtime cost, and not one read a sentence, so four consecutive
144
+ // reviews found the same wall. The step was rewritten to ask for the RECORD
145
+ // instead, and that version carried an explicit instruction not to restore the
146
+ // coaching wording early, because the wording is the signal for which world we
147
+ // are in.
148
+ //
149
+ // Task 1003977 shipped the fifth lens: an ADVISORY 'reader', scoped to
150
+ // copy-bearing diffs by THIS FILE'S hasCopyOrVisualWork — the same predicate
151
+ // that fires this cascade, so an artist review and the copy lens cover exactly
152
+ // the same set by construction, and widening one widens both. The voice guide
153
+ // it waited on is docs/voice.md (task 1003976). So there is a counterpart to
154
+ // coach, it has a written standard to be coached against, and the record the
155
+ // interim wording collected is what both were built from.
156
+ //
157
+ // What has NOT changed: the reader is non-gating and cannot hold a ship (ADR
158
+ // 0241 keeps the artist's judgement on the deploy), so this review is still a
159
+ // request triage may dismiss.
150
160
  function artistReviewBody(task) {
151
161
  return [
152
162
  `This is an ARTIST REVIEW, generated automatically because task ${task.id} put copy or visuals in front of a person. It is a REQUEST, not a gate — it blocks nothing that shipped, and triage may dismiss it.`,
153
163
  '',
154
- `STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade), then record what an artist would have caught and no lens did: slop, an off-voice line, a claim that does not land, a visual that reads as generated. Do not expect to find a copy verdict to argue with — the panel's four lenses read completion, code quality, security and runtime cost, and none of them reads a sentence (ADR 0298). Your record IS the compounding part: it is the evidence the advisory 'reader' lens is being built from, and the corpus the voice guide it waits on will be written against.`,
164
+ `STEP 1 — COACH THE READER. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade) and find the 'reader' row in signals.workers, with its copy_score in rubric_scores. That lens read the changed copy against docs/voice.md and cited rule ids. Say where it was RIGHT, where it was WRONG, and — the valuable one — what it did not see at all. A finding it filed that you disagree with is worth as much as one it missed: both are corrections to a lens that is listening, which is what this step could not ask for before task 1003977 shipped it (ADR 0298). If the miss is because no rule in docs/voice.md reaches it, that is a gap in the GUIDE — file it against the guide rather than expecting the lens to invent a rule at grade time. Two cases where there will be no reader row and that is not a fault: a ship that took the trivial-diff path (a single generalist shot, no panel), and one where the lens errored — it is advisory, so an outage is recorded and nothing is blocked.`,
155
165
  '',
156
166
  'STEP 2 — REMAKE OR TWEAK the copy/visuals if they need it. Your choice of route, both offered on purpose: OUTSIDE the AI chat (edit the surface directly and land it through the copy desk or the normal pipeline), or WITH it (draft in chat, then land). Either way it lands as a claimed, graded, shipped task like everything else — there is no live CMS (goal 1000074\'s hard rule).',
157
167
  '',
@@ -213,15 +213,24 @@ await test('a copy/visual ship generates the two-step artist review', () => {
213
213
  assert.match(spec.title, /7001/);
214
214
  // Both steps, and both routes of step 2, are in the body.
215
215
  //
216
- // STEP 1's label tracks ADR 0298 (task 1003937). It used to read
217
- // "REVIEW THE GRADER … coach it", which asked for something no artist could
218
- // do: the panel's four lenses read completion, code quality, security and
219
- // runtime cost, so there was no copy verdict to coach. Until the advisory
220
- // reader worker ships (task 1003977, itself gated on the voice guide in
221
- // task 1003976) the step asks for the RECORD instead — hence the pin on the
222
- // new label AND on the ADR reference, which is what stops a well-meaning
223
- // copy-edit from restoring the unfollowable version without reading why.
224
- assert.match(spec.description, /STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE/);
216
+ // STEP 1's label tracks ADR 0298 (task 1003937), and it has now turned twice.
217
+ // It first read "REVIEW THE GRADER … coach it", which asked for something no
218
+ // artist could do: the panel's four lenses read completion, code quality,
219
+ // security and runtime cost, so there was no copy verdict to coach. It was
220
+ // rewritten to ask for the RECORD instead, as an explicitly INTERIM wording,
221
+ // pinned here so a well-meaning copy-edit could not restore the unfollowable
222
+ // version without reading why.
223
+ //
224
+ // Task 1003977 shipped the advisory reader lens and the voice guide it waited
225
+ // on (task 1003976), so the step is coaching again — and this pin moves with
226
+ // it. The two must turn TOGETHER: the wording is the signal for which world
227
+ // we are in, so a body that still said "no lens reads a sentence" after the
228
+ // lens landed would send every artist looking for a counterpart that is
229
+ // sitting right there in the grade record.
230
+ assert.match(spec.description, /STEP 1 — COACH THE READER/);
231
+ assert.match(spec.description, /docs\/voice\.md/, 'and names the written standard to coach against');
232
+ assert.doesNotMatch(spec.description, /none of them reads a sentence/,
233
+ 'the interim claim is gone — it stopped being true when the reader shipped');
225
234
  assert.match(spec.description, /ADR 0298/, 'the step names the decision that shaped it');
226
235
  assert.match(spec.description, /STEP 2 — REMAKE OR TWEAK/);
227
236
  assert.match(spec.description, /OUTSIDE the AI chat/);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.889",
3
+ "version": "1.19.891",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.889",
9
+ "version": "1.19.891",
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.889",
3
+ "version": "1.19.891",
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",
@@ -67,6 +67,10 @@ const FILES = [
67
67
  'scripts/gds/start.js',
68
68
  'scripts/gds/context-pack.js', // start.js renders through this
69
69
  'scripts/gds/status.js',
70
+ // status.js resolves its default version at runtime instead of naming one
71
+ // (task 1004071) -- so the packaged CLI needs this too, or `bongos status`
72
+ // dies on a missing module. tests/cli_package.mjs caught exactly that.
73
+ 'scripts/gds/version-select.js',
70
74
  'scripts/gds/task.js',
71
75
  'scripts/gds/recall.js',
72
76
  'scripts/gds/cost.js',
@@ -12,6 +12,14 @@ const crypto = require('node:crypto');
12
12
  const { spawn } = require('node:child_process');
13
13
  const { cliClient } = require('./cli-lib');
14
14
  const grader = require('../../modules/grading/grader');
15
+ // task 1003977: the ONE definition of "does this ship put copy in front of a
16
+ // person". It lives in the lifecycle module and the grading module may not
17
+ // require a sibling (ADR 0083), so this script — which is not a module and
18
+ // already requires grading directly — is the seam where the two meet. Writing a
19
+ // second predicate inside grading would let the at-ship reader lens and the
20
+ // after-the-fact artist review disagree about what they cover, and widening the
21
+ // set would then have to be done twice.
22
+ const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
15
23
  const { resolveBuilderModel } = require('./ship-memory-sync.js');
16
24
  const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
17
25
  const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
@@ -285,9 +293,16 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
285
293
  lenses_dropped: ['hacker', 'efficiency'],
286
294
  };
287
295
  } else {
288
- const panel = grader.WORKERS.DEFAULT_PANEL;
296
+ // task 1003977: the advisory Reader rides along on copy-bearing diffs only.
297
+ // Where the predicate says no, `panel` is byte-identical to DEFAULT_PANEL
298
+ // and the ship costs exactly what it costs today.
299
+ const copyBearing = hasCopyOrVisualWork(task);
300
+ const panel = grader.WORKERS.panelFor({ copyBearing });
289
301
  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`);
290
- console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
302
+ console.log(` gate: every GATING worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
303
+ console.log(copyBearing
304
+ ? ` copy lens: ON — this task is copy-bearing, so the advisory 'reader' reads the changed strings against docs/voice.md. It is NON-GATING: nothing it reports, and no error or timeout it hits, can stop this ship.`
305
+ : ` copy lens: off — not a copy-bearing task (no copy-proposal source, not artist-discipline, module not in COPY_VISUAL_MODULES), so the panel is the usual four.`);
291
306
  if (priorRound) {
292
307
  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`);
293
308
  }
@@ -300,8 +315,19 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
300
315
  builderRank, // SEC #863: drives the permission-path pre-pass
301
316
  builderModel, // task 1002659: real session model → cross-model grader pick
302
317
  priorRound, // task 1002662: delta-aware re-grade context (panel path only)
318
+ workers: panel, // task 1003977: DEFAULT_PANEL, plus the advisory reader when copy-bearing
303
319
  runOpts,
304
320
  });
321
+ // A SKIPPED LENS IS RECORDED, NEVER SILENT. Without this a grade with no
322
+ // copy_score is indistinguishable from one where the copy was read and
323
+ // found clean — a missing score would read as a passing one. Same pattern
324
+ // and same reason as the trivial path's `lenses_dropped` above.
325
+ if (!copyBearing) {
326
+ result.signals = {
327
+ ...(result.signals || {}),
328
+ reader_skipped: 'not_copy_bearing',
329
+ };
330
+ }
305
331
  }
306
332
  const wallMs = Date.now() - t0;
307
333
  // Decorate signals with the local shortstat — useful in audit-trail.
@@ -19,14 +19,24 @@
19
19
  //
20
20
  // Exit codes: 0 ok · 1 network/api error · 2 bad invocation · 3 not found.
21
21
 
22
- const { API_BASE, cliClient, assertInstanceMatch } = require('./cli-lib');
22
+ const { API_BASE, cliClient, assertInstanceMatch, cliExit } = require('./cli-lib');
23
23
  const { resolveEnv } = require('../../src/instance-config');
24
+ const { resolveDefaultVersion } = require('./version-select.js');
24
25
 
25
- // The active internal version; override with <PREFIX>_STATUS_DEFAULT_VERSION (or pass
26
- // --version). Bumped GDS-V4 → BONGOS-V1 (task 1709, ADR 0103): GDS-V4 was
27
- // consolidated into BONGOS-V1 (the Cloud Bongos platform version) and frozen, so
28
- // the bare `/status` card should default to the live version, not a sealed one.
29
- const DEFAULT_VERSION = resolveEnv('STATUS_DEFAULT_VERSION') || 'BONGOS-V1';
26
+ // The active internal version is RESOLVED, never named here. Override with
27
+ // <PREFIX>_STATUS_DEFAULT_VERSION or --version.
28
+ //
29
+ // WHY THERE IS NO LITERAL (task 1004071). This line used to read
30
+ // const DEFAULT_VERSION = resolveEnv('STATUS_DEFAULT_VERSION') || 'BONGOS-V1';
31
+ // under a comment promising "the bare /status card should default to the live
32
+ // version, not a sealed one" — the same fix task 1709 had already applied once,
33
+ // for GDS-V4 → BONGOS-V1. BONGOS-V1 then sealed, nobody bumped the literal, and
34
+ // /status served a shipped version's closing scoreboard (140/203 satisfied) as
35
+ // the project's current status. It reads as a healthy project, which is why it
36
+ // sat: nothing in the output says `shipped`, and /status is what a session runs
37
+ // precisely when it does NOT already know. Bumping the literal again would only
38
+ // re-arm the same bomb for whoever seals BONGOS-V2, so the rule is derived from
39
+ // `versions.status` instead — the selection capture.js has always used.
30
40
 
31
41
  // Compact, status-first ordering for the "remaining: …" summaries.
32
42
  const STATUS_ORDER = ['shipped', 'confirmed', 'completed', 'claimed', 'ready', 'blocked', 'backlog'];
@@ -53,7 +63,36 @@ function parseArgs(argv) {
53
63
  else if (!criterion) criterion = a.toUpperCase();
54
64
  else return { error: `unexpected argument: ${a}` };
55
65
  }
56
- return { version: version || DEFAULT_VERSION, criterion, json, widget, hook };
66
+ // No `versionExplicit` companion flag: `version !== null` already IS that
67
+ // fact, and resolveVersion returns `defaulted`/`source` for anyone who needs
68
+ // to act on it. A second field carrying the same truth is one that can drift.
69
+ return { version: version || null, criterion, json, widget, hook };
70
+ }
71
+
72
+ // Resolve which version this invocation is about. Precedence: an explicit
73
+ // --version (or bare token) > the env override > the live BUILDING version.
74
+ // Returns { version, defaulted, source }; version is NULL when nothing is
75
+ // building, which the caller must treat as "stop and say so", never as a guess.
76
+ // Deps are injectable so the precedence is unit-tested without a session.
77
+ async function resolveVersion(opts, deps = {}) {
78
+ if (opts.version) return { version: opts.version, defaulted: false, source: 'flag' };
79
+ const env = (deps.resolveEnv || resolveEnv)('STATUS_DEFAULT_VERSION');
80
+ if (env) return { version: env, defaulted: true, source: 'env' };
81
+ const resolve = deps.resolveDefaultVersion || resolveDefaultVersion;
82
+ const building = await resolve();
83
+ if (building) return { version: building, defaulted: true, source: 'building' };
84
+ return { version: null, defaulted: true, source: 'none' };
85
+ }
86
+
87
+ // One line naming a version the USER did not, so a cross-version answer cannot
88
+ // pass unnoticed. `Cn` is version-scoped and the versions number independently
89
+ // — BONGOS-V1 C8 is `money-one-unit`, BONGOS-V2 C8 is `wa6-kernel-and-packs` —
90
+ // so a silently-defaulted version answers a different question than the one
91
+ // asked. Empty when the user named the version: they already know.
92
+ function defaultedVersionNote(r) {
93
+ if (!r || !r.defaulted || !r.version) return '';
94
+ const why = r.source === 'env' ? 'from STATUS_DEFAULT_VERSION' : 'the version currently building';
95
+ return `(no version given — showing ${r.version}, ${why}. Pass --version to pick another.)`;
57
96
  }
58
97
 
59
98
  async function fetchProgress(version) {
@@ -325,7 +364,38 @@ async function main() {
325
364
  // apiCall/cliClient guard, so without this `bongos status` would silently print another
326
365
  // instance's progress. Skip only in --hook mode (the automated card path stays fail-open).
327
366
  if (!opts.hook) await assertInstanceMatch();
328
- const data = await fetchProgress(opts.version);
367
+
368
+ // Resolve the version before anything fetches. --hook stays FAIL-OPEN like the
369
+ // instance check above: the automated card path must degrade, not throw, so a
370
+ // lookup that cannot answer emits an empty envelope rather than killing the hook.
371
+ //
372
+ // COST: one extra sequential GET /versions before the progress fetch, and it
373
+ // cannot be parallelised — the id IS the second call's path. It is paid only
374
+ // when no version was named: --version and STATUS_DEFAULT_VERSION both
375
+ // short-circuit it, so an instance that minds the round trip on the
376
+ // session-start card path sets the env var once and pays nothing. That is the
377
+ // deliberate trade against the alternative, which is the literal that spent
378
+ // months reporting a sealed version as current.
379
+ const picked = await resolveVersion(opts).catch(() => ({ version: null, defaulted: true, source: 'none' }));
380
+ if (!picked.version) {
381
+ if (opts.hook) {
382
+ process.stdout.write(JSON.stringify({
383
+ widgets_enabled: false, html: '', markdown: '',
384
+ warnings: ['status: no version is currently `building` — nothing to report.'],
385
+ }) + '\n');
386
+ return;
387
+ }
388
+ console.error('status: no version is currently `building` — cannot resolve a default.');
389
+ console.error(' Open one, or pass --version explicitly (e.g. --version BONGOS-V2).');
390
+ // cliExit, NOT process.exit: resolveVersion above just made a network round
391
+ // trip, and a hard exit while the request machinery is tearing down aborts
392
+ // natively on Windows + Node 24 (UV_HANDLE_CLOSING) — exit 127 AFTER the
393
+ // message prints, so a caller reads "tooling broken" instead of "no building
394
+ // version". The other exits in this file are pre-request or have substantial
395
+ // rendering between; this branch has neither. See scripts/gds/CLAUDE.md.
396
+ return cliExit(3);
397
+ }
398
+ const data = await fetchProgress(picked.version);
329
399
 
330
400
  // Resolve the requested criterion (if any) once — every output mode needs it.
331
401
  let c = null;
@@ -333,7 +403,10 @@ async function main() {
333
403
  const n = Number(opts.criterion.slice(1));
334
404
  c = data.criteria.find((x) => x.cnum === n);
335
405
  if (!c) {
336
- console.error(`status: ${opts.version} has no criterion ${opts.criterion} (it has C1–C${data.criteria.length}).`);
406
+ // Name the version, not just the criterion: on a DEFAULTED version this is
407
+ // the reader's only clue that C13 was looked up somewhere they didn't pick.
408
+ console.error(`status: ${picked.version} has no criterion ${opts.criterion} (it has C1–C${data.criteria.length}).`);
409
+ if (picked.defaulted) console.error(` ${defaultedVersionNote(picked)}`);
337
410
  process.exit(3);
338
411
  }
339
412
  }
@@ -356,9 +429,12 @@ async function main() {
356
429
  // else the whole version rollup).
357
430
  if (opts.json) { console.log(JSON.stringify(c || data, null, 2)); return; }
358
431
 
359
- // Default human output — the existing plain-text rollup, unchanged.
360
- if (c) { printOne(data, c); return; }
361
- printAll(data);
432
+ // Default human output — the existing plain-text rollup, plus one line when
433
+ // the version was resolved rather than chosen (--json/--widget/--hook are
434
+ // machine interfaces and stay byte-identical).
435
+ if (c) printOne(data, c); else printAll(data);
436
+ const note = defaultedVersionNote(picked);
437
+ if (note) console.log(` ${note}\n`);
362
438
  }
363
439
 
364
440
  if (require.main === module) {
@@ -368,4 +444,4 @@ if (require.main === module) {
368
444
  });
369
445
  }
370
446
 
371
- module.exports = { parseArgs, renderStatusWidget, renderStatusMarkdown };
447
+ module.exports = { parseArgs, resolveVersion, defaultedVersionNote, renderStatusWidget, renderStatusMarkdown };
@@ -35,13 +35,31 @@ const VERSION_LITERAL = String.raw`['"][A-Z][A-Z0-9]*-V\d+['"]`;
35
35
  const VERSION_FIELD = String.raw`version_id|versionId|suggested_version|target_version|DEFAULT_VERSION|VERSION_ID`;
36
36
  const ASSIGNED = new RegExp(`(?:${VERSION_FIELD})\\s*['"]?\\s*[:=][^,;)]*${VERSION_LITERAL}`);
37
37
  const DEFAULTED = new RegExp(`--version[^\\n]*?\\|\\|\\s*${VERSION_LITERAL}`);
38
+ // A version field FALLING BACK to a literal through `||`. ASSIGNED cannot see
39
+ // this shape: its `[^,;)]*` stops at the first `)`, so any call between the `=`
40
+ // and the literal hides it — `DEFAULT_VERSION = resolveEnv('X') || 'BONGOS-V1'`
41
+ // scanned CLEAN while `DEFAULT_VERSION = 'BONGOS-V1'` was caught (task 1004071).
42
+ // That is the exact line that served a sealed version as /status's default for
43
+ // months. Kept as its own narrow pattern rather than loosening ASSIGNED to
44
+ // `[^;]*`, which would fire on any line merely mentioning a field and a literal
45
+ // — the over-broad source assertion this repo keeps relearning (learning 1000196).
46
+ const FALLBACK = new RegExp(`(?:${VERSION_FIELD})\\s*['"]?\\s*[:=][^;]*?\\|\\|\\s*${VERSION_LITERAL}`);
38
47
 
39
48
  // path::reason. A justified entry, one line, like every other guard here.
49
+ //
50
+ // A REASON MAY CITE THE CODE'S SHAPE, NEVER THE DATABASE'S CONTENTS (task
51
+ // 1004071). status.js was exempted because "TWO versions are currently building
52
+ // — so 'most recent building' is not a derivable answer here." True the day it
53
+ // was written; false once CB-V1 sealed. Nothing re-checks prose, so the entry
54
+ // outlived its justification in silence and /status went on serving a SHIPPED
55
+ // version's closing scoreboard as the project's current status. An entry whose
56
+ // truth depends on rows is a timer nobody set. `version_literals.mjs` now fails
57
+ // CI on an entry that is outright DEAD; it cannot police one that is merely
58
+ // stale, which is why that judgement has to be made here, when the entry is
59
+ // written.
40
60
  const VERSION_LITERAL_ALLOW = new Map([
41
61
  ['scripts/gds/grade-smoke.js',
42
62
  'GOOD_TASK / BAD_TASK are in-memory fixtures handed to grader.gradeSubagent; they are never POSTed, so no version_closed can refuse them and the literal cannot rot into a failure.'],
43
- ['scripts/gds/status.js',
44
- 'DEFAULT_VERSION is an owner-facing dashboard default with an env override, and TWO versions are currently building — so "most recent building" is not a derivable answer here. Retargeting what /status shows by default is an owner decision, not a refactor.'],
45
63
  ]);
46
64
 
47
65
  function isExempt(rel) {
@@ -67,7 +85,7 @@ function scanScripts(files, read) {
67
85
  scanned += 1;
68
86
  if (VERSION_LITERAL_ALLOW.has(rel)) continue;
69
87
  stripComments(src, rel).forEach((line, i) => {
70
- if (ASSIGNED.test(line) || DEFAULTED.test(line)) {
88
+ if (ASSIGNED.test(line) || DEFAULTED.test(line) || FALLBACK.test(line)) {
71
89
  violations.push(
72
90
  `${rel}:${i + 1} names a version id where one should be RESOLVED: ${line.trim().slice(0, 90)}`
73
91
  + ' — a literal works until that version ships, then version_closed refuses every write and the'
@@ -119,4 +137,4 @@ function main() {
119
137
 
120
138
  if (require.main === module) main();
121
139
 
122
- module.exports = { checkNoVersionLiterals, scanScripts, stripComments, isExempt, VERSION_LITERAL_ALLOW, ASSIGNED, DEFAULTED };
140
+ module.exports = { checkNoVersionLiterals, scanScripts, stripComments, isExempt, VERSION_LITERAL_ALLOW, ASSIGNED, DEFAULTED, FALLBACK };
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.889'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.891'; // 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');
@@ -128,11 +128,19 @@ test('nothing parseable is null', () => {
128
128
  test('GRADE_SHAPE_KEYS is the exported score-key list', () => {
129
129
  // Exported so workers and grader.js agree on what "grade-shaped" means; the
130
130
  // preference test above depends on functional_fidelity being in this list.
131
+ //
132
+ // ONE ENTRY PER WORKER THAT REPORTS A SCORE. task 1003977 added copy_score
133
+ // with the reader: reader.js re-exports this extractJson, so a worker missing
134
+ // from this list silently loses the score-based disambiguation every other
135
+ // worker gets. Kept as an exact list rather than loosened, because that
136
+ // silence is the whole reason to pin it — a new worker must appear here in
137
+ // the same change that adds it.
131
138
  assert.deepEqual(GRADE_SHAPE_KEYS, [
132
139
  'functional_fidelity',
133
140
  'code_quality',
134
141
  'security_score',
135
142
  'efficiency_score',
143
+ 'copy_score',
136
144
  ]);
137
145
  });
138
146