@bongos/core 1.19.888 → 1.19.890

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.
@@ -171,6 +171,14 @@ const INTEGRATION = new Set([
171
171
  // real engine can show the CASE guard leaving a NULL alone on an actual use,
172
172
  // which is exactly where the removed timeout would come back.
173
173
  'auth_session_expiry_db',
174
+ // task 1003372 (audit A1, core_249): that hashing the bearer at rest does not
175
+ // sign anybody out. The DB-free sibling (session_token_hash) stands in front of
176
+ // the pool and proves the plaintext never reaches a query — the negative that
177
+ // matters — but it cannot show that Postgres accepts the migration, nor that
178
+ // the digest the backfill computes in SQL equals the one the kernel computes in
179
+ // Node. That mismatch would leave every new session working and every migrated
180
+ // one dead, which is the failure a source-reading test walks straight past.
181
+ 'session_token_hash_db',
174
182
  'scouting_rollup_fanout', 'session_records',
175
183
  // task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
176
184
  // SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
@@ -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.
@@ -23,6 +23,7 @@
23
23
 
24
24
  'use strict';
25
25
 
26
+ const crypto = require('node:crypto');
26
27
  const { pool } = require('./pool');
27
28
  const seams = require('../module-seams');
28
29
 
@@ -277,8 +278,37 @@ async function accessStateByLogin(login) {
277
278
  // code agree on one number.
278
279
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
279
280
 
281
+ // sessionTokenHash — the ONE place a bearer becomes the value this table stores
282
+ // (task 1003372, audit A1, migration core_249). SHA-256 hex, matching the
283
+ // migration's `encode(sha256(token::bytea), 'hex')` byte for byte, so a row
284
+ // backfilled in SQL and a row minted here resolve identically.
285
+ //
286
+ // WHY A FAST HASH. A session token is 256 bits of CSPRNG, not a password: there
287
+ // is no dictionary to run, so a KDF would buy nothing and cost real latency on
288
+ // every authenticated request. Same reasoning, written out at length, in
289
+ // modules/platform-identity/platform-identity.js (`sha256hex`).
290
+ //
291
+ // WHY NO timingSafeEqual HERE, THOUGH THE TASK NAMED IT. The pattern the task
292
+ // points at compares a presented secret against one it already fetched by
293
+ // client_id, so the comparison happens in JS and must be constant-time. A session
294
+ // has no second identifier — the bearer IS the lookup key — so the comparison is
295
+ // the indexed `WHERE token_hash = $1` below, and there is no JS compare to make
296
+ // constant-time. Adding a timingSafeEqual after that lookup would compare the
297
+ // hash to itself: vacuously true, and worse than nothing, because it would read
298
+ // as a control that is doing something. The property the task wanted — a guessed
299
+ // token leaks no information about how close it was — holds here because the
300
+ // index is probed with a full digest, never a prefix.
301
+ function sessionTokenHash(token) {
302
+ return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
303
+ }
304
+
280
305
  // createSession — mint a builder_sessions row.
281
306
  //
307
+ // THE PLAINTEXT BEARER IS NEVER STORED (task 1003372). `token` is hashed on the
308
+ // way in and the column that held it is gone (core_249). The raw value is
309
+ // returned to the caller — it is their input, handed back so the mint response
310
+ // can carry it to the client — and after that it exists only in the client.
311
+ //
282
312
  // A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
283
313
  // lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
284
314
  // restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
@@ -290,12 +320,15 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
290
320
  async function createSession({ builderId, token, source, userAgent, expiresAt }) {
291
321
  const effectiveExpiresAt = expiresAt ?? null;
292
322
  const { rows } = await pool.query(
293
- `INSERT INTO builder_sessions (builder_id, token, source, user_agent, expires_at)
323
+ `INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
294
324
  VALUES ($1, $2, $3, $4, $5)
295
- RETURNING id, builder_id, token, source, created_at, expires_at`,
296
- [builderId, token, source, userAgent ?? null, effectiveExpiresAt]
325
+ RETURNING id, builder_id, source, created_at, expires_at`,
326
+ [builderId, sessionTokenHash(token), source, userAgent ?? null, effectiveExpiresAt]
297
327
  );
298
- return rows[0];
328
+ // `token` is re-attached from the ARGUMENT, never read back from the row — the
329
+ // row no longer holds it. Callers that pass a token and read one back are
330
+ // unchanged; what changed is that the database never saw the plaintext.
331
+ return { ...rows[0], token };
299
332
  }
300
333
 
301
334
  // lookupSession — resolve a live (unrevoked, unexpired) session token to its
@@ -346,8 +379,8 @@ async function lookupSession(token) {
346
379
  b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
347
380
  FROM builder_sessions s
348
381
  JOIN builders b ON b.id = s.builder_id
349
- WHERE s.token = $1`,
350
- [token]
382
+ WHERE s.token_hash = $1`,
383
+ [sessionTokenHash(token)]
351
384
  );
352
385
  const row = rows[0];
353
386
  if (!row) return null;
@@ -376,6 +409,9 @@ async function lookupSession(token) {
376
409
  module.exports = {
377
410
  withTx,
378
411
  sessionPermsSelect,
412
+ // Exported for db.js's revokeSession (which is handed a bearer, not an id) and
413
+ // for the tests that assert the plaintext never reaches a query parameter.
414
+ sessionTokenHash,
379
415
  getBuilderById,
380
416
  getFoundingBuilder,
381
417
  listActiveBuilders,
package/src/bongos/db.js CHANGED
@@ -44,6 +44,9 @@ const {
44
44
  // internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
45
45
  RANKS, SESSION_TTL_MS, upsertBuilder, seatFirstAdminIfUnclaimed,
46
46
  getBuilderByGithubId, hasInvitedAccessRequest, createSession, lookupSession,
47
+ // The bearer → stored-value transform (task 1003372, core_249). revokeSession
48
+ // below is the one helper here still handed a raw bearer rather than an id.
49
+ sessionTokenHash,
47
50
  } = require('./db-kernel');
48
51
 
49
52
  // gradePassCount removed with builderRollingAvgGrade (its only core user) — that
@@ -728,10 +731,14 @@ async function hasCliSession(builderId) {
728
731
  // lookupSession — carved to ./db-kernel (ADR 0102). Re-exported above.
729
732
 
730
733
 
734
+ // Takes the raw bearer (its callers are sign-out paths holding a cookie or an
735
+ // Authorization header, not a session id) and matches on the stored hash —
736
+ // core_249 dropped the plaintext column, so the bearer itself is no longer
737
+ // something this table can be searched by.
731
738
  async function revokeSession(token) {
732
739
  await pool.query(
733
- `UPDATE builder_sessions SET revoked_at = now() WHERE token = $1 AND revoked_at IS NULL`,
734
- [token]
740
+ `UPDATE builder_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
741
+ [sessionTokenHash(token)]
735
742
  );
736
743
  }
737
744
 
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.888'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.890'; // 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');
@@ -67,7 +67,7 @@ await pool.query(`
67
67
  display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
68
68
  status text DEFAULT 'active', system_role text, anthropic_user_id text);
69
69
  CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
70
- token text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
70
+ token_hash text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
71
71
  last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
72
72
  INSERT INTO builders (github_login) VALUES ('probe');
73
73
  `);
@@ -86,6 +86,11 @@ const t = async (name, fn) => {
86
86
  catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
87
87
  };
88
88
  const row = async (sql, p) => (await client.query(sql, p)).rows[0];
89
+ // core_249: the bearer is no longer a column, so a probe that wants "the row for
90
+ // this token" has to ask by the stored digest. Uses the kernel's OWN helper, so a
91
+ // change to the hash cannot leave this file quietly matching nothing — a mismatch
92
+ // would make every assertion below read a missing row and fail loudly.
93
+ const h = (token) => kernel.sessionTokenHash(token);
89
94
  // refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
90
95
  const settle = () => new Promise((r) => setTimeout(r, 300));
91
96
 
@@ -110,67 +115,67 @@ await t('lookupSession resolves a CLI bearer to its builder', async () => {
110
115
  await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
111
116
  await kernel.lookupSession('cli-tok');
112
117
  await settle();
113
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
118
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).expires_at, null);
114
119
  });
115
120
 
116
121
  await t('refresh-on-use still bumps last_used_at on that same row', async () => {
117
- const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
118
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token='cli-tok'`);
122
+ const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
123
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token_hash='${h('cli-tok')}'`);
119
124
  await kernel.lookupSession('cli-tok');
120
125
  await settle();
121
- const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
126
+ const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
122
127
  assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
123
128
  });
124
129
 
125
130
  await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
126
131
  await kernel.lookupSession('web-tok');
127
132
  await settle();
128
- const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
133
+ const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('web-tok')}'`)).expires_at;
129
134
  assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
130
135
  });
131
136
 
132
137
  await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
133
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
134
- VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
138
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at)
139
+ VALUES (1,'${h('dead-tok')}','web', now() - interval '1 hour')`);
135
140
  assert.equal(await kernel.lookupSession('dead-tok'), null);
136
141
  });
137
142
 
138
143
  await t('a revoked CLI session is still REFUSED', async () => {
139
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, revoked_at)
140
- VALUES (1,'revoked-tok','cli', now())`);
144
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, revoked_at)
145
+ VALUES (1,'${h('revoked-tok')}','cli', now())`);
141
146
  assert.equal(await kernel.lookupSession('revoked-tok'), null);
142
147
  });
143
148
 
144
149
  // The migration, against rows shaped like the ones live instances actually hold.
145
150
  const migrationSql = readFileSync(MIGRATION, 'utf8');
146
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at) VALUES
147
- (1,'old-cli','cli', now() + interval '24 hours'),
148
- (1,'old-web','web', now() + interval '30 days'),
149
- (1,'old-revoked','cli', now() + interval '24 hours')`);
150
- await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token='old-revoked'`);
151
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at) VALUES
152
+ (1,'${h('old-cli')}','cli', now() + interval '24 hours'),
153
+ (1,'${h('old-web')}','web', now() + interval '30 days'),
154
+ (1,'${h('old-revoked')}','cli', now() + interval '24 hours')`);
155
+ await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash='${h('old-revoked')}'`);
151
156
  await client.query(migrationSql);
152
157
 
153
158
  await t('core_243 clears a pre-existing CLI expiry', async () => {
154
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
159
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
155
160
  });
156
161
 
157
162
  await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
158
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
163
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
159
164
  });
160
165
 
161
166
  await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
162
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-revoked'`)).expires_at, null);
167
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-revoked')}'`)).expires_at, null);
163
168
  });
164
169
 
165
170
  await t('core_243 is idempotent — a re-run changes nothing', async () => {
166
171
  await client.query(migrationSql);
167
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
168
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
172
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
173
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
169
174
  });
170
175
 
171
176
  // The whole point of the task, stated as a test.
172
177
  await t('a CLI session idle for 21 DAYS still authenticates', async () => {
173
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token='cli-tok'`);
178
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token_hash='${h('cli-tok')}'`);
174
179
  assert.notEqual(await kernel.lookupSession('cli-tok'), null);
175
180
  });
176
181
 
@@ -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
 
@@ -42,6 +42,9 @@ if (!process.env.DATABASE_URL) {
42
42
  }
43
43
 
44
44
  const { Pool } = require('pg');
45
+ // The kernel's own bearer → stored-digest transform (core_249), so this fixture
46
+ // cannot drift from how production mints a session.
47
+ const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
45
48
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
46
49
 
47
50
  // ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
@@ -137,9 +140,12 @@ async function seed() {
137
140
  [ARCHON, THETES]
138
141
  );
139
142
  await q(
140
- `INSERT INTO builder_sessions (builder_id, token, source)
143
+ // core_249: the bearer is stored as its SHA-256, so a fixture session has to
144
+ // be seeded the same way the kernel mints one — otherwise the HTTP calls
145
+ // below authenticate as nobody.
146
+ `INSERT INTO builder_sessions (builder_id, token_hash, source)
141
147
  VALUES ($1,$2,'web'), ($3,$4,'web')`,
142
- [ARCHON, ARCHON_TOKEN, THETES, THETES_TOKEN]
148
+ [ARCHON, sessionTokenHash(ARCHON_TOKEN), THETES, sessionTokenHash(THETES_TOKEN)]
143
149
  );
144
150
  const [idea] = await q(
145
151
  `INSERT INTO idea_inbox (title, body_md, captured_by)
@@ -0,0 +1,257 @@
1
+ // tests/grader_reader_lens.mjs — the advisory Reader lens (task 1003977, ADR 0298).
2
+ //
3
+ // The owner settled three terms on 2026-09-17 and each is pinned here:
4
+ // SCOPE copy-bearing diffs only, keyed off the ONE existing predicate.
5
+ // AUTHORITY advisory, full stop — it cannot fail, cannot reach 'concerns',
6
+ // and cannot block via an error or a timeout.
7
+ // STANDARD docs/voice.md, cited by rule id.
8
+ //
9
+ // The task names the error exemption as "the single most important test in this
10
+ // task", because a worker error normally HARD-FAILS the panel
11
+ // (panel_gate: 'ANY worker.error != null → passed=false'). It is §2 below.
12
+ //
13
+ // The subtlest case is §3: a HEALTHY reader must not mask a total grader
14
+ // outage. Before the non-gating partition, a passing fifth worker would make
15
+ // "every gating worker is missing signal" false, and a total outage would
16
+ // present as FAIL · 0/10 with every axis null — the exact regression class
17
+ // task 1003574 documents, where a builder sat dark on an unclearable re-grade.
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 { createRequire } from 'node:module';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const grader = require('../modules/grading/grader.js');
29
+ const WORKERS = require('../modules/grading/grader-workers/index.js');
30
+ const reader = require('../modules/grading/grader-workers/reader.js');
31
+ const { hasPanelEvidence } = require('../modules/grading/grader-score.js');
32
+ const { hasCopyOrVisualWork } = require('../modules/lifecycle/cascade.js');
33
+
34
+ const W = (kind, verdict, extra = {}) => ({
35
+ kind, name: kind, verdict, scores: {}, issues: [], confidence: 0.9, error: null, ...extra,
36
+ });
37
+ const healthyFour = () => [
38
+ W('narc', 'pass', { scores: { functional_fidelity: 9 } }),
39
+ W('quality', 'pass', { scores: { code_quality: 9, economy: 9 } }),
40
+ W('hacker', 'pass', { scores: { security_score: 9 } }),
41
+ W('efficiency', 'pass', { scores: { efficiency_score: 9 } }),
42
+ ];
43
+ const agg = (ws) => grader.aggregatePanel(ws, {});
44
+
45
+ // ---- 1. AUTHORITY: the verdict itself cannot be a blocking one -------------
46
+
47
+ test('computeVerdict returns "advisory" on EVERY path, including its own failures', () => {
48
+ // Structural, not a filter: there is no branch that returns pass/concerns/fail,
49
+ // so "it cannot block" is a property of the function rather than a promise
50
+ // kept by its callers.
51
+ const paths = [
52
+ ['clean', [{ copy_score: 10, issues: [] }, {}]],
53
+ ['bad copy', [{ copy_score: 0, issues: [{ severity: 'major', kind: 'break', rule: 'V1' }] }, {}]],
54
+ ['subagent error', [null, { subprocess_error: 'SUBAGENT_TIMEOUT' }]],
55
+ ['spawn failure', [null, { subprocess_error: 'SUBAGENT_SPAWN_FAILED' }]],
56
+ ['unparseable', ['not json at all', {}]],
57
+ ['schema error', [{ copy_score: 'banana', issues: [] }, {}]],
58
+ ['null parse', [null, {}]],
59
+ ];
60
+ for (const [name, [parsed, meta]] of paths) {
61
+ const v = reader.computeVerdict(parsed, meta);
62
+ assert.equal(v.verdict, 'advisory', `${name} → ${v.verdict}`);
63
+ assert.equal(v.verdict, reader.ADVISORY_VERDICT);
64
+ }
65
+ });
66
+
67
+ test('an errored Reader reports NO score rather than a zero', () => {
68
+ // 0/10 is a real reading ("would embarrass the project in front of a user").
69
+ // An outage is missing signal, not a bad signal — the distinction #943 draws
70
+ // for the whole panel, applied to one worker.
71
+ const v = reader.computeVerdict(null, { subprocess_error: 'SUBAGENT_TIMEOUT' });
72
+ assert.deepEqual(v.scores, {});
73
+ assert.equal(v.error, 'SUBAGENT_TIMEOUT');
74
+ });
75
+
76
+ test('a blocker severity is FLOORED to major, not dropped', () => {
77
+ // Dropping would hide a real observation; honouring would hand this lens the
78
+ // veto the owner's term denies it.
79
+ const v = reader.computeVerdict({ copy_score: 3, issues: [{ severity: 'blocker', kind: 'break', rule: 'V2' }] }, {});
80
+ assert.equal(v.issues.length, 1, 'the finding survives');
81
+ assert.equal(v.issues[0].severity, 'major');
82
+ assert.equal(v.issues[0].severity_floored_from, 'blocker');
83
+ });
84
+
85
+ // ---- 2. THE EXEMPTION THE TASK CALLS THE MOST IMPORTANT --------------------
86
+
87
+ test('NO Reader outcome can stop a ship — including an error, which normally hard-fails', () => {
88
+ const baseline = agg(healthyFour());
89
+ assert.equal(baseline.passed, true);
90
+
91
+ const cases = {
92
+ 'clean reader': W('reader', 'advisory', { scores: { copy_score: 9 } }),
93
+ 'copy_score 0': W('reader', 'advisory', { scores: { copy_score: 0 } }),
94
+ 'errored (timeout)': W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' }),
95
+ 'errored (prompt too long)': W('reader', 'advisory', { error: 'PROMPT_TOO_LONG' }),
96
+ 'two major findings': W('reader', 'advisory', { scores: { copy_score: 2 }, issues: [{ severity: 'major' }, { severity: 'major' }] }),
97
+ // Belt AND braces: even if a future edit made the worker emit a gating
98
+ // verdict, the panel partition still refuses to read it.
99
+ 'ILLEGALLY emits fail': W('reader', 'fail', { issues: [{ severity: 'blocker' }] }),
100
+ };
101
+ for (const [name, row] of Object.entries(cases)) {
102
+ const r = agg([...healthyFour(), row]);
103
+ assert.equal(r.passed, true, `${name} must not block the ship`);
104
+ assert.equal(r.signals.panel_outcome, 'pass', `${name} must not move the outcome`);
105
+ }
106
+ });
107
+
108
+ test('the Reader is absent from every gate count', () => {
109
+ const r = agg([...healthyFour(), W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' })]);
110
+ assert.equal(r.signals.worker_count, 4, 'the gate denominator counts gating workers only');
111
+ assert.equal(r.signals.error_count, 0, "an errored Reader is not a gate error");
112
+ assert.equal(r.signals.fail_count, 0);
113
+ assert.equal(r.signals.pass_count, 4);
114
+ });
115
+
116
+ // ---- 3. THE SUBTLE ONE -----------------------------------------------------
117
+
118
+ test('a HEALTHY Reader does not mask a total grader outage', () => {
119
+ const allErrored = ['narc', 'quality', 'hacker', 'efficiency'].map((k) => W(k, 'fail', { error: 'SUBAGENT_SPAWN_FAILED' }));
120
+ const without = agg(allErrored);
121
+ assert.equal(without.signals.panel_outcome, 'unavailable', 'baseline: a total outage is an outage');
122
+
123
+ const with_ = agg([...allErrored, W('reader', 'advisory', { scores: { copy_score: 9 } })]);
124
+ assert.equal(with_.signals.panel_outcome, 'unavailable',
125
+ 'a passing fifth worker must not turn an outage into a FAIL 0/10 (the task-1003574 class)');
126
+ assert.equal(with_.passed, false);
127
+ });
128
+
129
+ test("the Reader's confidence cannot move the server-gate median", () => {
130
+ const four = healthyFour();
131
+ const noReader = agg(four).signals.confidence;
132
+ const withReader = agg([...four, W('reader', 'advisory', { confidence: 0.01, scores: { copy_score: 1 } })]).signals.confidence;
133
+ assert.equal(withReader, noReader, 'a low-confidence Reader must not drag the forwarded confidence');
134
+ });
135
+
136
+ // ---- 4. THE SIGNAL STILL LANDS ---------------------------------------------
137
+
138
+ test('copy_score and the findings reach the RECORD even though the gate ignores them', () => {
139
+ const r = agg([...healthyFour(), W('reader', 'advisory', { scores: { copy_score: 4 }, issues: [{ severity: 'major', rule: 'V1' }] })]);
140
+ assert.equal(r.rubric_scores.copy_score, 4, 'the score is recorded');
141
+ assert.ok(r.signals.workers.some((w) => w.kind === 'reader'), 'the Reader row is in the record');
142
+ assert.equal(agg(healthyFour()).rubric_scores.copy_score, null, 'and is null when no Reader ran');
143
+ });
144
+
145
+ test('a Reader row still counts as evidence that the panel ran', () => {
146
+ // hasPanelEvidence is an `every` over the worker verdicts. Leave 'advisory'
147
+ // out of WORKER_VERDICTS and a fully-healthy five-worker panel reads as NO
148
+ // evidence, firing the implausible-pass guard on exactly the ships carrying
149
+ // the most lenses.
150
+ const rows = (extra = []) => ({ workers: [
151
+ { kind: 'narc', verdict: 'pass' }, { kind: 'quality', verdict: 'pass' },
152
+ { kind: 'hacker', verdict: 'pass' }, { kind: 'efficiency', verdict: 'pass' }, ...extra,
153
+ ] });
154
+ assert.equal(hasPanelEvidence(rows()), true);
155
+ assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'advisory' }])), true);
156
+ assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'nonsense' }])), false, 'the vocabulary is still closed');
157
+ });
158
+
159
+ // ---- 5. SCOPE --------------------------------------------------------------
160
+
161
+ test('the panel is byte-identical to today when the diff is not copy-bearing', () => {
162
+ assert.deepEqual(WORKERS.panelFor({ copyBearing: false }), WORKERS.DEFAULT_PANEL);
163
+ assert.deepEqual(WORKERS.panelFor(), WORKERS.DEFAULT_PANEL, 'and when the caller says nothing');
164
+ assert.deepEqual(WORKERS.panelFor({ copyBearing: true }), [...WORKERS.DEFAULT_PANEL, 'reader']);
165
+ });
166
+
167
+ test('scope keys off the ONE existing predicate, not a second copy', () => {
168
+ // The at-ship lens and the after-the-fact artist review must never disagree
169
+ // about what they cover. Widening COPY_VISUAL_MODULES must widen both at once,
170
+ // which it only does while there is one predicate.
171
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
172
+ assert.match(src, /hasCopyOrVisualWork/, 'the caller asks lifecycle for the answer');
173
+ assert.match(src, /panelFor\(\{ copyBearing \}\)/, 'and passes it down');
174
+ // RECURSIVE. A readdirSync of modules/grading alone skips grader-workers/,
175
+ // which holds reader.js — the file this assertion is most about. The first
176
+ // version of this test did exactly that and proved nothing it claimed.
177
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
178
+ const full = path.join(dir, e.name);
179
+ return e.isDirectory() ? walk(full) : (e.name.endsWith('.js') ? [full] : []);
180
+ });
181
+ const gradingFiles = walk(path.join(ROOT, 'modules', 'grading'));
182
+ assert.ok(gradingFiles.some((f) => f.endsWith(`grader-workers${path.sep}reader.js`)),
183
+ 'the scan reaches grader-workers/reader.js — otherwise it checks everything except the file in question');
184
+ for (const f of gradingFiles) {
185
+ const body = fs.readFileSync(f, 'utf8').replace(/^\s*\/\/.*$/gm, '');
186
+ assert.ok(!/COPY_VISUAL_MODULES|copy-proposal/.test(body),
187
+ `${path.relative(ROOT, f)} must not re-implement the copy predicate — grading cannot require lifecycle (ADR 0083), so the CALLER decides`);
188
+ }
189
+ // Sanity: the predicate really does discriminate.
190
+ assert.equal(hasCopyOrVisualWork({ discipline: 'artist' }), true);
191
+ assert.equal(hasCopyOrVisualWork({ module_key: 'hall-ui' }), true);
192
+ assert.equal(hasCopyOrVisualWork({ module_key: 'grading', discipline: 'engineer' }), false);
193
+ });
194
+
195
+ // ---- 6. STANDARD -----------------------------------------------------------
196
+
197
+ test('the prompt sends the worker to the written guide, and forbids an uncited finding', () => {
198
+ const p = reader.PROMPT_HEADER;
199
+ assert.match(p, /docs\/voice\.md/, 'the rubric is the voice guide');
200
+ assert.match(p, /cites no rule id is not a finding/i, "the guide's own rule travels with it");
201
+ // The hard/judgement split is what stops an advisory lens recording taste as
202
+ // fact, so it is pinned rather than left to the prompt's good intentions.
203
+ assert.match(p, /V1-V5/, 'hard rules may be asserted');
204
+ assert.match(p, /V6-V11/, 'judgement rules may only be flagged');
205
+ assert.match(p, /ADVISORY/, 'the worker is told it cannot block');
206
+ assert.ok(!/blocker/.test(p.split('## Return ONLY JSON')[1] || ''),
207
+ 'the JSON schema offers no blocker severity');
208
+ });
209
+
210
+ test('docs/voice.md exists — the lens has a standard to judge against', () => {
211
+ // The hard dependency (task 1003976). A copy lens with no written standard
212
+ // records taste and calls it a finding.
213
+ const guide = fs.readFileSync(path.join(ROOT, 'docs', 'voice.md'), 'utf8');
214
+ for (const id of ['V1', 'V2', 'V3', 'V4', 'V5']) {
215
+ assert.ok(guide.includes(`## ${id} —`) || guide.includes(`## ${id} `), `${id} is a rule in the guide`);
216
+ }
217
+ });
218
+
219
+ // ---- 7. THE DONE-WHEN CLAUSE THAT LIVES IN ANOTHER FILE ---------------------
220
+
221
+ test('the artist review now coaches the reader, because there is a lens listening', () => {
222
+ // The task's own DONE WHEN: "Then STEP 1 of the artist review reverts to true
223
+ // coaching — see the comment in modules/lifecycle/cascade.js, which says so
224
+ // and asks not to be reverted early." The interim wording was explicitly a
225
+ // signal for which world we are in, so shipping the lens without turning it
226
+ // leaves the instruction to artists describing a panel that no longer exists.
227
+ const { artistReviewBody } = require('../modules/lifecycle/cascade.js');
228
+ const body = artistReviewBody({ id: '1234' });
229
+ assert.match(body, /COACH THE READER/, 'STEP 1 asks for coaching again');
230
+ assert.match(body, /docs\/voice\.md/, 'and names the standard to coach against');
231
+ assert.ok(!/none of them reads a sentence/.test(body),
232
+ 'the interim claim that no lens reads a sentence is gone — it is no longer true');
233
+ // Still a request, never a gate (ADR 0241) — unchanged by this task.
234
+ assert.match(body, /REQUEST, not a gate/);
235
+ });
236
+
237
+ test('the cascade and the copy lens cover the SAME set by construction', () => {
238
+ // Both key off hasCopyOrVisualWork, so an artist review exists for exactly
239
+ // the ships a reader ran on. If these ever diverge, an artist is asked to
240
+ // coach a lens that never looked.
241
+ const cascadeSrc = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'), 'utf8');
242
+ const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
243
+ assert.match(cascadeSrc, /hasCopyOrVisualWork/);
244
+ assert.match(shipSrc, /hasCopyOrVisualWork/);
245
+ });
246
+
247
+ // ---- 8. COST ---------------------------------------------------------------
248
+
249
+ test('the Reader sits at the heavy timeout tier, so it cannot change worst-case wall time', () => {
250
+ // The four already sit at 360s and workers run in parallel, so the panel's
251
+ // wall time is max() and a fifth at the same budget does not move it.
252
+ const t = WORKERS.timeoutForWorker(reader, {});
253
+ assert.equal(t, 360_000);
254
+ for (const k of ['narc', 'quality', 'hacker', 'efficiency']) {
255
+ assert.equal(WORKERS.timeoutForWorker(WORKERS.REGISTRY[k], {}), t, `${k} shares the tier`);
256
+ }
257
+ });