@bongos/core 1.19.783 → 1.19.784

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.
@@ -42,6 +42,37 @@
42
42
 
43
43
  const shortSid = (sid) => String(sid || '').slice(0, 8) || '—';
44
44
 
45
+ // task 1002516 — the transcript accessibility dimension (criterion
46
+ // `accessibility-fluency-profile`, clause 4). Headed "Plain speech" rather than
47
+ // "Accessibility" because this page already lives under a shell whose only
48
+ // other accessibility control is the zoom, and two different meanings of one
49
+ // word in one view teaches nobody anything.
50
+ //
51
+ // A null score is "not scored", NOT a zero: the builder's own profile waived
52
+ // every signal (an engineer who asked for code terms cannot be marked down for
53
+ // using them), or the row predates the dimension. Both render as an em dash
54
+ // with the reason on hover — never as a bad number.
55
+ const ACCESSIBILITY_TITLE = {
56
+ clear: 'Read plainly for this builder’s profile.',
57
+ workable: 'Mostly plain for this builder’s profile.',
58
+ dense: 'Dense for this builder’s profile — jargon, terminal-first, or implementation framing.',
59
+ opaque: 'Hard to follow for this builder’s profile.',
60
+ };
61
+ // Emits the whole <td>: the page's warn colour is defined on `.ledger td.warn`
62
+ // (sessions.css), so the class has to land on the cell, not on a span inside it.
63
+ function accessibilityCell(s) {
64
+ const raw = s.accessibility_score;
65
+ const score = raw == null ? null : Number(raw);
66
+ if (score == null || !Number.isFinite(score)) {
67
+ return '<td class="ledger__num" title="Not scored — this builder’s own profile waived every signal, or the session predates this measure.">—</td>';
68
+ }
69
+ const title = ACCESSIBILITY_TITLE[String(s.accessibility_band || '')] || '';
70
+ // Below the rubric's own 6.0 threshold reads as the warning the tool-errors
71
+ // column already uses, so one colour means one thing across the row.
72
+ const cls = `ledger__num${score < 6 ? ' warn' : ''}`;
73
+ return `<td class="${cls}" title="${escapeHtml(title)}">${escapeHtml(score.toFixed(1))}</td>`;
74
+ }
75
+
45
76
  // ---- rendering -----------------------------------------------------------
46
77
 
47
78
  // Stats reflect the WHOLE corpus (server-computed `totals`), not just the
@@ -75,7 +106,7 @@
75
106
  const body = $('#sessions-body');
76
107
  if (!sessions.length) {
77
108
  body.innerHTML =
78
- '<tr><td colspan="9" class="ledger__empty">No sessions recorded yet. They land here as builders ship and end sessions.</td></tr>';
109
+ '<tr><td colspan="10" class="ledger__empty">No sessions recorded yet. They land here as builders ship and end sessions.</td></tr>';
79
110
  return;
80
111
  }
81
112
  body.innerHTML = sessions
@@ -101,6 +132,7 @@
101
132
  `<td class="ledger__num" title="${fmtInt(s.total_tokens)} tokens">${escapeHtml(fmtCompact(s.total_tokens))}</td>` +
102
133
  `<td class="ledger__num${errs > 0 ? ' warn' : ''}">${errs}</td>` +
103
134
  `<td class="ledger__num">${escapeHtml(fmtDuration(s.duration_seconds))}</td>` +
135
+ accessibilityCell(s) +
104
136
  `<td class="ledger__date">${escapeHtml(fmtDate(s.uploaded_at))}</td>` +
105
137
  '</tr>'
106
138
  );
@@ -524,10 +524,34 @@ async function enableAutoMerge({ nodeId }, deps = {}) {
524
524
  // the PR waits for a human nobody told. Returns { ok, reason } — reason is null on
525
525
  // success, else a short scrubbed string safe to hand the (ownership-gated) builder.
526
526
  // enableAutoMerge stays the boolean façade so existing callers are untouched.
527
+ // logAutoMergeFailure — put the arming failure in the SERVER log, not only in the
528
+ // response body (task 1003511). task 1001431 gave enableAutoMergeDetailed a reason,
529
+ // but it travelled in ONE direction — the publish-branch 201 — so "seven PRs in a row
530
+ // came back auto_merge:false" was diagnosable only by whoever still had the response
531
+ // body. A cause the server computes and then discards is the same outage as one it
532
+ // never computed. Through the module-api logger seam, not a raw console call:
533
+ // console_call_count (config/fitness-baselines.json) is a shrink-only ratchet.
534
+ // Lazy-required so DB-free tests stay hermetic; wrapped so a logger fault can never
535
+ // turn a best-effort arming miss into a thrown publish. Scrubbed below, not at the
536
+ // call sites, so a future caller that forgets cannot leak a token into the log.
537
+ function logAutoMergeFailure(reason, context, logImpl) {
538
+ try {
539
+ const sink = logImpl || require('../../src/module-api').logger('lifecycle');
540
+ sink.warn('auto-merge could not be armed (PR needs a manual merge)',
541
+ { reason: scrubCredential(String(reason == null ? '' : reason)), context });
542
+ } catch (_) { /* logging is never load-bearing */ }
543
+ }
544
+
527
545
  async function enableAutoMergeDetailed({ nodeId }, deps = {}) {
546
+ const context = deps.logContext || 'publish';
547
+ const fail = (raw) => {
548
+ const reason = scrubCredential(String(raw == null ? '' : raw));
549
+ logAutoMergeFailure(reason, context, deps.logImpl);
550
+ return { ok: false, reason };
551
+ };
528
552
  const token = await resolveToken(deps);
529
- if (!token) return { ok: false, reason: 'no GitHub token could be resolved' };
530
- if (!nodeId) return { ok: false, reason: 'the pull request has no node id to arm' };
553
+ if (!token) return fail('no GitHub token could be resolved');
554
+ if (!nodeId) return fail('the pull request has no node id to arm');
531
555
  const doFetch = deps.fetchImpl || fetch;
532
556
  const query = `mutation($id:ID!){enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:MERGE}){clientMutationId}}`;
533
557
  try {
@@ -536,15 +560,15 @@ async function enableAutoMergeDetailed({ nodeId }, deps = {}) {
536
560
  headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
537
561
  body: JSON.stringify({ query, variables: { id: nodeId } }),
538
562
  });
539
- if (!res.ok) return { ok: false, reason: `GitHub GraphQL returned HTTP ${res.status}` };
563
+ if (!res.ok) return fail(`GitHub GraphQL returned HTTP ${res.status}`);
540
564
  const out = await res.json();
541
565
  if (!out || !out.errors) return { ok: true, reason: null };
542
566
  // GitHub answers a repo with auto-merge disabled with a plain GraphQL error;
543
567
  // its own message is the actionable one, so pass it through (truncated).
544
568
  const msg = out.errors.map((e) => e && e.message).filter(Boolean).join('; ');
545
- return { ok: false, reason: scrubCredential(msg || 'GitHub GraphQL rejected the mutation').slice(0, 300) };
569
+ return fail(scrubCredential(msg || 'GitHub GraphQL rejected the mutation').slice(0, 300));
546
570
  } catch (e) {
547
- return { ok: false, reason: scrubCredential((e && e.message) || 'the GraphQL request threw').slice(0, 300) };
571
+ return fail(scrubCredential((e && e.message) || 'the GraphQL request threw').slice(0, 300));
548
572
  }
549
573
  }
550
574
 
@@ -558,7 +582,9 @@ async function enableAutoMergeDetailed({ nodeId }, deps = {}) {
558
582
  // as a permanently-missing workflow, so both are "no readable deploy to wait for";
559
583
  // task 1002472 for the 404 case);
560
584
  // 'unavailable' = a transient read failure (5xx/429/other) — task 1002724).
561
- // deploy_probe = { state, http, detail } — WHY the probe landed where it did,
585
+ // deploy_probe = { state, reason, http, detail } — WHY the probe landed where it did;
586
+ // `reason` separates the two permanent 'unconfigured' shapes that share a state:
587
+ // 'no_deploy_workflow' (404) vs 'actions_read_forbidden' (403, no Actions:read).
562
588
  // so a stuck disposition is diagnosable without a shell on the host.
563
589
  // Mirrors ship.js pollCiDeploy's two-stage check (PR merged -> deploy run).
564
590
  async function getPublishStatus({ branch }, deps = {}) {
@@ -596,7 +622,7 @@ async function getPublishStatus({ branch }, deps = {}) {
596
622
 
597
623
  // Merged → did the prod-deploy workflow succeed for the merge commit?
598
624
  let deployStatus = 'pending';
599
- let deployProbe = { state: 'pending', http: null, detail: 'no matching deploy run yet' };
625
+ let deployProbe = { state: 'pending', reason: 'no_deploy_run_yet', http: null, detail: 'no matching deploy run yet' };
600
626
  try {
601
627
  const runsRes = await doFetch(
602
628
  `${api}/actions/workflows/deploy-prod.yml/runs?branch=main&per_page=15`,
@@ -624,7 +650,9 @@ async function getPublishStatus({ branch }, deps = {}) {
624
650
  if (exact || afterMerge) {
625
651
  deployStatus = 'success';
626
652
  deployProbe = {
627
- state: 'success', http: runsRes.status,
653
+ state: 'success',
654
+ reason: exact ? 'deploy_run_for_merge' : 'main_deploy_after_merge',
655
+ http: runsRes.status,
628
656
  detail: exact ? 'deploy run for the merge commit' : 'main deploy succeeded after the merge (ADR 0161 carrier)',
629
657
  };
630
658
  }
@@ -648,7 +676,12 @@ async function getPublishStatus({ branch }, deps = {}) {
648
676
  // (deployProbeBlind) to route around a probe that will 403 on every sweep forever.
649
677
  deployStatus = 'unconfigured';
650
678
  deployProbe = {
651
- state: 'unconfigured', http: runsRes.status,
679
+ state: 'unconfigured',
680
+ // THE SECOND AMBIGUITY (task 1003511): both causes are permanent and must
681
+ // keep the same state so landConfirmed() can still flip a merged PR, but they
682
+ // need DIFFERENT actions — 404 nothing to fix, 403 grant Actions:read.
683
+ reason: runsRes.status === 404 ? 'no_deploy_workflow' : 'actions_read_forbidden',
684
+ http: runsRes.status,
652
685
  detail: runsRes.status === 404
653
686
  ? 'no deploy-prod.yml in this repo'
654
687
  : 'workflow-runs read forbidden (token lacks Actions:read) — treated as no readable deploy to wait for',
@@ -666,11 +699,11 @@ async function getPublishStatus({ branch }, deps = {}) {
666
699
  // deploy state — and let the reconciler decide whether git history already
667
700
  // proves the land.
668
701
  deployStatus = 'unavailable';
669
- deployProbe = { state: 'unavailable', http: runsRes.status, detail: `workflow-runs read failed (HTTP ${runsRes.status})` };
702
+ deployProbe = { state: 'unavailable', reason: 'workflow_runs_read_failed', http: runsRes.status, detail: `workflow-runs read failed (HTTP ${runsRes.status})` };
670
703
  }
671
704
  } catch (e) {
672
705
  // A network-level miss is genuinely transient — keep waiting, but say so.
673
- deployProbe = { state: 'pending', http: null, detail: `workflow-runs fetch threw: ${e && e.message}` };
706
+ deployProbe = { state: 'pending', reason: 'probe_threw', http: null, detail: `workflow-runs fetch threw: ${e && e.message}` };
674
707
  }
675
708
  return {
676
709
  pr_state: pr.state, merged: true, merge_sha: mergeSha,
@@ -1390,12 +1423,34 @@ async function recheckPullRequest({ number }, deps = {}) {
1390
1423
  if (reopenDelayMs > 0) await doSleep(reopenDelayMs);
1391
1424
  const reopened = await patchState('open');
1392
1425
  let autoMerge = false;
1426
+ let autoMergeReason = null;
1393
1427
  try {
1394
- if (reopened && reopened.node_id) autoMerge = await enableAutoMerge({ nodeId: reopened.node_id }, deps);
1395
- } catch (_) {
1396
- /* auto-merge re-arm is best-effort; mergeIfGreen sweep backstops it */
1428
+ if (reopened && reopened.node_id) {
1429
+ // The DETAILED variant: the boolean façade discarded the cause here, and no
1430
+ // response body carried it either — the quietest of the four (task 1003511).
1431
+ const armed = await enableAutoMergeDetailed(
1432
+ { nodeId: reopened.node_id },
1433
+ { ...deps, logContext: `recheck pr #${number}` }
1434
+ );
1435
+ autoMerge = armed.ok;
1436
+ autoMergeReason = armed.reason;
1437
+ } else {
1438
+ autoMergeReason = 'the reopened pull request returned no node id';
1439
+ logAutoMergeFailure(autoMergeReason, `recheck pr #${number}`, deps.logImpl);
1440
+ }
1441
+ } catch (e) {
1442
+ /* best-effort; the mergeIfGreen sweep backstops it — but this throw used to be
1443
+ invisible even to the operator. */
1444
+ autoMergeReason = scrubCredential((e && e.message) || 'the re-arm threw').slice(0, 300);
1445
+ logAutoMergeFailure(autoMergeReason, `recheck pr #${number}`, deps.logImpl);
1397
1446
  }
1398
- return { reopened: true, auto_merge: autoMerge, node_id: (reopened && reopened.node_id) || null };
1447
+ return {
1448
+ reopened: true,
1449
+ auto_merge: autoMerge,
1450
+ node_id: (reopened && reopened.node_id) || null,
1451
+ // Only present when the re-arm did not take — the recheck itself still succeeded.
1452
+ ...(autoMerge ? {} : { auto_merge_reason: autoMergeReason }),
1453
+ };
1399
1454
  }
1400
1455
 
1401
1456
  module.exports = {
@@ -0,0 +1,373 @@
1
+ // modules/sessions/accessibility.js — the ACCESSIBILITY dimension of the session
2
+ // evaluator (task 1002516; clause 4 of criterion `accessibility-fluency-profile`).
3
+ //
4
+ // WHAT IT SCORES, AND WHY IT LIVES HERE AND NOT IN THE GRADER PANEL.
5
+ //
6
+ // The criterion asks for an accessibility dimension "scored from the transcript".
7
+ // The v4 grader panel (modules/grading/) never sees a transcript — every worker
8
+ // is handed { task, valueSummary, diff, changedFiles, ... } and judges the DIFF.
9
+ // The session corpus is the only place the transcript exists, and this module is
10
+ // the only scorer that reads it, so the dimension belongs here. ADR 0291 records
11
+ // that split in full; the short version is that a diff lens cannot see how a
12
+ // builder was TALKED TO, which is the whole subject of the criterion.
13
+ //
14
+ // THE THREE SIGNALS are the ones the criterion names, and only those:
15
+ // 1. unexplained-jargon density — SWE vocabulary used without defining it.
16
+ // 2. terminal-where-a-UI-exists — a shell command handed over for something
17
+ // the instance already has a supported click-path for.
18
+ // 3. outcome-vs-implementation framing — trade-offs framed as design-pattern
19
+ // taxonomy instead of cost, time and what the reader will see.
20
+ //
21
+ // FOUR CONTRACTS THIS FILE MUST NEVER BREAK
22
+ //
23
+ // A. PROFILE-RELATIVE, NOT ABSOLUTE. The criterion is explicit that "an expert's
24
+ // terse experience is never degraded to help a newcomer". So a signal the
25
+ // builder's own interaction profile SANCTIONS is not scored down — it is not
26
+ // scored at all (`waived`), and a session whose every signal is waived scores
27
+ // `null`, not a number. A profile-sanctioned reply can therefore never produce
28
+ // a low score, which is a stronger guarantee than a generous threshold.
29
+ //
30
+ // B. TERSENESS IS NEVER A PENALTY. `verbosity` is not an input to this file and
31
+ // must not become one. Every rate below is per 1000 SAMPLED CHARACTERS, so a
32
+ // short reply and a long one are measured at the same density. Penalising
33
+ // brevity would invert the point of the whole criterion (the same reasoning
34
+ // that keeps `verbosity: concise` from dropping a render rung — task 1002518).
35
+ //
36
+ // C. ADVISORY, NEVER A GATE. This score is coaching, recorded on the session row
37
+ // and read by the BFG. It is not on the ship path and holds nothing. ADR 0241
38
+ // settled that a taste-shaped judgement may hold a deploy and never a
39
+ // builder's ship; a transcript-shaped one is the same kind of judgement.
40
+ //
41
+ // D. FAIL TOWARDS NOT PENALISING. Every detector below is deliberately
42
+ // high-precision and low-recall: a jargon term with any nearby definition
43
+ // marker counts as explained, and a command only counts when it lands on a
44
+ // subject with a KNOWN click-path. An undercount is a quiet score; an
45
+ // overcount is a metric that punishes correct work, which the task's own
46
+ // "watch out" calls actively harmful.
47
+ //
48
+ // SAMPLING, STATED HONESTLY. The digest stores only the first 160 characters of
49
+ // each assistant turn (session-digest-build.js snippetOf), so this measures the
50
+ // OPENING of every reply, not its whole body. That is a real bound and it is
51
+ // reported rather than hidden: `sampled_chars` and `confidence` travel with the
52
+ // score so a reader knows how much prose it actually saw. Widening the snippet
53
+ // would inflate every session row against the SR-17 disk bounds, so the sample
54
+ // stays where it is and the score stays honest about it.
55
+
56
+ // NO REQUIRE OF builder-settings, deliberately. ADR 0083 forbids a module
57
+ // importing a sibling and fitness.js enforces it, so the interaction profile
58
+ // arrives as DATA: the upload route resolves the `builder-settings` kernel port,
59
+ // asks it for the effective profile, and hands the plain object in. This file
60
+ // only needs to know which VALUES mean what, so it keys on the literal strings
61
+ // below — and tests/session_accessibility.mjs asserts those strings still exist
62
+ // in interaction-prefs, so a rename there trips a test rather than silently
63
+ // un-waiving an engineer's session.
64
+ const PROFILE_DEFAULTS = Object.freeze({
65
+ technical_level: 'non_technical',
66
+ toolchain_familiarity: 'new',
67
+ });
68
+ const PROFILE_VALUES = Object.freeze({
69
+ technical_level: ['non_technical', 'mixed', 'engineer'],
70
+ toolchain_familiarity: ['new', 'comfortable'],
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Signal 1 — unexplained jargon
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // Software-engineering vocabulary a non-technical reader is not expected to
78
+ // know. Deliberately NOT a list of this project's own nouns (task, claim, ship,
79
+ // goal, blocker): those are the vocabulary of the product the builder is using,
80
+ // which they learn by using it, not the SWE taxonomy the criterion is about.
81
+ const JARGON_TERMS = [
82
+ 'idempotent', 'idempotency', 'memoize', 'memoise', 'mutex', 'race condition',
83
+ 'closure', 'middleware', 'singleton', 'dependency injection', 'polymorphic',
84
+ 'monkey-patch', 'monkeypatch', 'refactor', 'regex', 'regexp', 'jsonb', 'upsert',
85
+ 'transpile', 'webhook', 'CORS', 'JWT', 'OAuth', 'stack trace', 'nullable',
86
+ 'coerce', 'serialize', 'serialise', 'deserialize', 'deserialise', 'async',
87
+ 'promise chain', 'immutable', 'mutation', 'schema', 'migration', 'ORM',
88
+ 'N+1', 'big-O', 'hoisting', 'shim', 'polyfill', 'tree-shaking', 'sharding',
89
+ 'debounce', 'throttle', 'monorepo', 'worktree', 'rebase', 'cherry-pick',
90
+ 'fast-forward', 'SIGTERM', 'SIGKILL', 'ENOENT', 'stdout', 'stderr', 'daemon',
91
+ 'cron', 'socket', 'mutable', 'idempotently', 'abstraction layer',
92
+ 'thunk', 'reducer', 'hydrate', 'marshalling', 'marshaling', 'nondeterministic',
93
+ 'race-condition', 'deadlock', 'backpressure', 'fan-out', 'fanout',
94
+ ];
95
+
96
+ // A jargon hit is EXPLAINED when a definition marker follows it closely. The
97
+ // window is generous and the marker list is broad on purpose (contract D): a
98
+ // false "explained" costs a quiet score, a false "unexplained" punishes a reply
99
+ // that did define its terms.
100
+ // WORD-BOUNDED, not substring. A naive indexOf made "ORM" fire on "format" and
101
+ // "CORS" on "scores" — a metric that penalises ordinary English is worse than no
102
+ // metric at all (contract D). Each term is compiled once, with a \b only on the
103
+ // edges that are word characters so "N+1" and "big-O" still anchor correctly.
104
+ function escapeRe(s) {
105
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
106
+ }
107
+ const JARGON_RE = JARGON_TERMS.map((term) => ({
108
+ term,
109
+ re: new RegExp(
110
+ (/^\w/.test(term) ? '\\b' : '') + escapeRe(term) + (/\w$/.test(term) ? '\\b' : ''),
111
+ 'gi'
112
+ ),
113
+ }));
114
+
115
+ const DEFINITION_WINDOW = 70;
116
+ const DEFINITION_MARKER_RE =
117
+ /\(|\s—\s|\s--\s|\bi\.e\.|\bthat is\b|\bwhich (?:is|means)\b|\bmeaning\b|\bmeans\b|\bin other words\b|\bthink of (?:it|this) as\b/i;
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Signal 2 — terminal where a UI exists
121
+ // ---------------------------------------------------------------------------
122
+
123
+ // The shape of handing someone a command to run. Not "a command appears in the
124
+ // text" — the agent running a command itself is its own job and not a signal.
125
+ const COMMAND_HANDOFF_RE =
126
+ /\brun\s+(?:this|the following|`|node |npm |git |psql )|\bin (?:your|a) terminal\b|\bfrom the command line\b|\bpaste (?:this|the following)\b|\b(?:type|enter) this (?:command|in)\b/i;
127
+
128
+ // The subjects that ALREADY have a supported click-path in the hall. The path is
129
+ // carried beside the pattern because it is the coaching too — a finding that can
130
+ // name where the button is, is worth acting on; one that just says "you used the
131
+ // terminal" is not. Keep this table honest: a subject whose UI does not exist
132
+ // must not be listed, or the signal starts penalising the only way to do the job.
133
+ const UI_BACKED_SUBJECTS = [
134
+ { re: /\bapi[- ]key|\bart[- ]key|\bgemini key|\banthropic key\b/i, path: 'Settings → API keys' },
135
+ { re: /\binteraction profile\b|\btechnical level\b|\bverbosity\b|\bwandering\b|\bpreference/i, path: 'Settings → Interaction' },
136
+ { re: /\bsign in\b|\bsign-in\b|\bre-?auth|\bsession (?:token|expired)\b/i, path: 'Settings → Session' },
137
+ { re: /\bclaim (?:a |the )?task\b|\bwhat can i work on\b/i, path: 'the hall task list' },
138
+ { re: /\bblocker\b/i, path: 'the hall blockers page' },
139
+ { re: /\bleaderboard\b|\bcredits?\b|\bdrachmae\b/i, path: 'the hall leaderboard' },
140
+ ];
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Signal 3 — implementation framing over outcome framing
144
+ // ---------------------------------------------------------------------------
145
+
146
+ // Framing a trade-off as design-pattern / architecture taxonomy.
147
+ const IMPLEMENTATION_FRAMING_RE =
148
+ /\b(?:design pattern|architecture|abstraction|coupling|cohesion|inheritance|interface|polymorphism|dependency graph|module boundary|type system|data model|control flow|separation of concerns|the reducer|the middleware|the schema|refactor(?:ing)?)\b/i;
149
+
150
+ // Framing the same trade-off as cost, time, or what the reader will see. The
151
+ // criterion's own words: "cost, time and what the end user will see".
152
+ const OUTCOME_FRAMING_RE =
153
+ /\$\d|\b(?:costs?|cheaper|expensive|free|per month|minutes?|hours?|days?|faster|slower|you(?:'ll| will) see|the (?:user|reader|owner) (?:will|would|sees)|what changes for you|downtime|on screen|in the browser)\b/i;
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Penalty curves
157
+ // ---------------------------------------------------------------------------
158
+ //
159
+ // Each signal contributes at most `max` points off a 10. `free` is the rate a
160
+ // session may carry without any penalty at all; `span` is how much further the
161
+ // rate has to climb to reach the full penalty. Rates are per 1000 sampled
162
+ // characters (contract B). The numbers are deliberately round and documented
163
+ // rather than tuned to a corpus — the tests pin the PROPERTIES (monotonic,
164
+ // clamped, waiver-respecting), not these magnitudes, so retuning is cheap.
165
+ const SIGNAL_SPEC = Object.freeze({
166
+ unexplained_jargon: { max: 4, free: 1.0, span: 4.0 },
167
+ terminal_where_ui_exists: { max: 3, free: 0.0, span: 1.5 },
168
+ implementation_over_outcome: { max: 3, free: 0.0, span: 1.0 },
169
+ });
170
+
171
+ // Below this much sampled prose the rates are too noisy to stand behind.
172
+ const LOW_CONFIDENCE_CHARS = 1000;
173
+
174
+ function clamp(n, lo, hi) {
175
+ const x = Number(n);
176
+ if (!Number.isFinite(x)) return lo;
177
+ return Math.max(lo, Math.min(hi, x));
178
+ }
179
+
180
+ // resolveProfile — the effective interaction profile this session is scored
181
+ // against. A missing/partial profile falls back to the field defaults, which are
182
+ // the accessible-leaning posture (interaction-prefs.js FIELDS) — so a session
183
+ // with no profile at all is scored as the default builder, never unscored.
184
+ function resolveProfile(profile) {
185
+ const raw = profile && typeof profile === 'object' ? profile : {};
186
+ const out = {};
187
+ for (const f of Object.keys(PROFILE_DEFAULTS)) {
188
+ out[f] = PROFILE_VALUES[f].includes(raw[f]) ? raw[f] : PROFILE_DEFAULTS[f];
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // waiversFor — which signals this profile sanctions, and WHY. The reason string
194
+ // is carried onto the result so a builder reading a waived signal sees the
195
+ // setting that waived it rather than a silent absence. Pure.
196
+ //
197
+ // `mixed` technical level relaxes rather than waives: its own contract says
198
+ // "light SWE vocabulary is fine without defining every term", which is a wider
199
+ // allowance, not a blank cheque — so it doubles the free rate instead.
200
+ function waiversFor(profile) {
201
+ const p = resolveProfile(profile);
202
+ const w = {};
203
+ if (p.technical_level === 'engineer') {
204
+ w.unexplained_jargon = 'technical_level: engineer — this builder asked to be spoken to in code and architecture terms.';
205
+ w.implementation_over_outcome = 'technical_level: engineer — implementation framing is what this builder asked for.';
206
+ }
207
+ if (p.toolchain_familiarity === 'comfortable') {
208
+ w.terminal_where_ui_exists = 'toolchain_familiarity: comfortable — this builder does not need the click-path first.';
209
+ }
210
+ return w;
211
+ }
212
+
213
+ // Free-rate multiplier for a signal under this profile (1 = the spec's own free
214
+ // rate). Only `mixed` currently widens anything; everything else scores at spec.
215
+ function leniencyFor(profile, signal) {
216
+ const p = resolveProfile(profile);
217
+ if (signal === 'unexplained_jargon' && p.technical_level === 'mixed') return 2;
218
+ return 1;
219
+ }
220
+
221
+ // assistantProse — the sampled assistant text this scorer may read, as
222
+ // { text, chars }. USER turns are excluded on purpose: a tool result or the
223
+ // builder's own words are not the agent's prose, and scoring them would make the
224
+ // metric depend on what the builder typed.
225
+ function assistantProse(detail) {
226
+ const turns = (Array.isArray(detail) ? detail : []).filter(
227
+ (t) => t && typeof t === 'object' && t.role === 'assistant' && typeof t.snippet === 'string' && t.snippet
228
+ );
229
+ const snippets = turns.map((t) => t.snippet);
230
+ return { snippets, chars: snippets.reduce((n, s) => n + s.length, 0) };
231
+ }
232
+
233
+ // countUnexplainedJargon — jargon hits with no definition marker within
234
+ // DEFINITION_WINDOW characters after them. Returns { count, evidence }.
235
+ function countUnexplainedJargon(snippets) {
236
+ let count = 0;
237
+ let evidence = '';
238
+ for (const s of snippets) {
239
+ for (const { term, re } of JARGON_RE) {
240
+ re.lastIndex = 0;
241
+ let m;
242
+ while ((m = re.exec(s)) !== null) {
243
+ const end = m.index + m[0].length;
244
+ const after = s.slice(end, end + DEFINITION_WINDOW);
245
+ if (DEFINITION_MARKER_RE.test(after)) continue;
246
+ count++;
247
+ if (!evidence) evidence = `"${term}" undefined in: ${s.slice(0, 80)}`;
248
+ }
249
+ }
250
+ }
251
+ return { count, evidence };
252
+ }
253
+
254
+ // countTerminalWhereUiExists — a command handed over on a subject that has a
255
+ // known click-path. Returns { count, evidence }; the evidence names the path so
256
+ // the finding is actionable.
257
+ function countTerminalWhereUiExists(snippets) {
258
+ let count = 0;
259
+ let evidence = '';
260
+ for (const s of snippets) {
261
+ if (!COMMAND_HANDOFF_RE.test(s)) continue;
262
+ const subject = UI_BACKED_SUBJECTS.find((u) => u.re.test(s));
263
+ if (!subject) continue;
264
+ count++;
265
+ if (!evidence) evidence = `${subject.path} exists for this — "${s.slice(0, 80)}"`;
266
+ }
267
+ return { count, evidence };
268
+ }
269
+
270
+ // countImplementationOverOutcome — turns that frame in implementation taxonomy
271
+ // with NO outcome framing anywhere in the same turn. A turn carrying both is not
272
+ // a finding: naming the architecture AND what it costs is exactly right.
273
+ function countImplementationOverOutcome(snippets) {
274
+ let count = 0;
275
+ let evidence = '';
276
+ for (const s of snippets) {
277
+ if (!IMPLEMENTATION_FRAMING_RE.test(s)) continue;
278
+ if (OUTCOME_FRAMING_RE.test(s)) continue;
279
+ count++;
280
+ if (!evidence) evidence = `implementation framing, no cost/time/user framing: ${s.slice(0, 80)}`;
281
+ }
282
+ return { count, evidence };
283
+ }
284
+
285
+ const DETECTORS = Object.freeze({
286
+ unexplained_jargon: countUnexplainedJargon,
287
+ terminal_where_ui_exists: countTerminalWhereUiExists,
288
+ implementation_over_outcome: countImplementationOverOutcome,
289
+ });
290
+
291
+ function bandFor(score) {
292
+ if (score >= 8) return 'clear';
293
+ if (score >= 6) return 'workable';
294
+ if (score >= 4) return 'dense';
295
+ return 'opaque';
296
+ }
297
+
298
+ // scoreAccessibility — the whole dimension for one session's digest detail.
299
+ // PURE (no I/O), so it is unit-tested and can be computed either at upload (the
300
+ // route, from the authenticated builder's own profile) or at eval time by the
301
+ // BFG for a historical row that predates the column.
302
+ //
303
+ // Returns:
304
+ // { score, band, signals[], profile, sampled_chars, sampled_turns, confidence }
305
+ // `score` is null when every signal is waived (contract A) or when there was no
306
+ // assistant prose to read at all — a null is the honest answer and the surfaces
307
+ // render it as "—" rather than inventing a 10.
308
+ function scoreAccessibility(detail, opts = {}) {
309
+ const profile = resolveProfile(opts.profile);
310
+ const waivers = waiversFor(profile);
311
+ const { snippets, chars } = assistantProse(detail);
312
+ const per1000 = chars > 0 ? 1000 / chars : 0;
313
+
314
+ const signals = [];
315
+ let penalty = 0;
316
+ let scored = 0;
317
+ for (const [type, spec] of Object.entries(SIGNAL_SPEC)) {
318
+ if (waivers[type]) {
319
+ signals.push({ type, waived: true, reason: waivers[type] });
320
+ continue;
321
+ }
322
+ scored++;
323
+ const { count, evidence } = DETECTORS[type](snippets);
324
+ const rate = Number((count * per1000).toFixed(3));
325
+ const free = spec.free * leniencyFor(profile, type);
326
+ const over = Math.max(0, rate - free);
327
+ const p = spec.span > 0 ? clamp((over / spec.span) * spec.max, 0, spec.max) : 0;
328
+ penalty += p;
329
+ signals.push({
330
+ type,
331
+ waived: false,
332
+ count,
333
+ rate,
334
+ penalty: Number(p.toFixed(2)),
335
+ ...(evidence ? { evidence } : {}),
336
+ });
337
+ }
338
+
339
+ const scorable = scored > 0 && chars > 0;
340
+ const score = scorable ? Number(clamp(10 - penalty, 0, 10).toFixed(1)) : null;
341
+ return {
342
+ score,
343
+ band: score == null ? 'n/a' : bandFor(score),
344
+ signals,
345
+ profile,
346
+ sampled_chars: chars,
347
+ sampled_turns: snippets.length,
348
+ // Confidence describes the SAMPLE, not the score — a fully-waived session
349
+ // still read real prose, and saying "none" there would misreport why the
350
+ // score is absent (the band already says `n/a`).
351
+ confidence: chars === 0 ? 'none' : chars < LOW_CONFIDENCE_CHARS ? 'low' : 'normal',
352
+ };
353
+ }
354
+
355
+ module.exports = {
356
+ scoreAccessibility,
357
+ // Exported so the tests can pin the contracts (waivers, leniency, each
358
+ // detector) without reconstructing a whole digest for every case.
359
+ resolveProfile,
360
+ waiversFor,
361
+ leniencyFor,
362
+ assistantProse,
363
+ countUnexplainedJargon,
364
+ countTerminalWhereUiExists,
365
+ countImplementationOverOutcome,
366
+ bandFor,
367
+ SIGNAL_SPEC,
368
+ JARGON_TERMS,
369
+ UI_BACKED_SUBJECTS,
370
+ LOW_CONFIDENCE_CHARS,
371
+ PROFILE_DEFAULTS,
372
+ PROFILE_VALUES,
373
+ };
@@ -490,6 +490,11 @@ const SESSION_ORDER = Object.freeze({
490
490
  tokens: 'sr.total_tokens DESC, sr.uploaded_at DESC',
491
491
  duration: 'sr.duration_seconds DESC NULLS LAST, sr.uploaded_at DESC',
492
492
  drachmae: 'sr.drachmae_earned DESC, sr.uploaded_at DESC',
493
+ // task 1002516 — WORST FIRST, like tool_errors: the sessions worth coaching are
494
+ // the low-scoring ones. NULLS LAST keeps the un-scorable rows (a fully-waived
495
+ // expert profile, or a row uploaded before the dimension existed) off the top
496
+ // of a ranking they have no score to earn.
497
+ accessibility: "(sr.metrics->'accessibility'->>'score')::numeric ASC NULLS LAST, sr.uploaded_at DESC",
493
498
  });
494
499
  function resolveSessionOrder(key) {
495
500
  return Object.prototype.hasOwnProperty.call(SESSION_ORDER, key) ? SESSION_ORDER[key] : SESSION_ORDER.recent;
@@ -544,6 +549,12 @@ const SESSION_SUMMARY_COLS = `
544
549
  sr.task_ids, sr.drachmae_earned, sr.total_tokens, sr.duration_seconds,
545
550
  COALESCE((sr.metrics->>'tool_error_count')::int, 0) AS tool_errors,
546
551
  COALESCE((sr.metrics->>'assistant_turns')::int, 0) AS assistant_turns,
552
+ -- task 1002516: the transcript accessibility dimension. NOT coalesced to 0 —
553
+ -- a missing score means "not scored" (waived, or pre-dating the dimension) and
554
+ -- a zero would read as the worst possible session. The band travels with it so
555
+ -- the ledger does not have to re-derive the thresholds in the browser.
556
+ (sr.metrics->'accessibility'->>'score')::numeric AS accessibility_score,
557
+ (sr.metrics->'accessibility'->>'band') AS accessibility_band,
547
558
  sr.uploaded_at`;
548
559
 
549
560
  // searchSessions — cross-builder search over the corpus, filtered by session
@@ -31,7 +31,12 @@ const express = require('express');
31
31
  const api = require('../../../src/module-api');
32
32
  const auth = api; // requireBuilder / requireRank live on the doorway surface
33
33
  const db = require('../db');
34
- const seams = api; // resolveOptional('reward') — the economy port, kernel-mediated
34
+ // task 1002516 (clause 4 of `accessibility-fluency-profile`): the transcript
35
+ // accessibility dimension. The scorer is this module's own PURE file — it takes
36
+ // the interaction profile as data precisely so it does not have to require
37
+ // builder-settings, which ADR 0083 forbids.
38
+ const { scoreAccessibility } = require('../accessibility');
39
+ const seams = api; // resolveOptional('reward' | 'builder-settings') — kernel-mediated ports
35
40
  const { validateOrRespond, parseId } = api;
36
41
 
37
42
  const UPLOAD_BODY_LIMIT = '4mb';
@@ -237,6 +242,35 @@ module.exports = function buildSessionsRouter() {
237
242
  const metrics = body.metrics && typeof body.metrics === 'object' ? { ...body.metrics } : {};
238
243
  delete metrics.total_tokens;
239
244
  delete metrics.model_usage; // promoted to its own column; avoid two copies
245
+ // task 1002516 — the accessibility dimension is computed HERE, server-side,
246
+ // for the same reason drachmae is: a score the uploader could supply is a
247
+ // score nobody can read. It is also the only place the builder's own
248
+ // interaction profile is available without another round trip — the
249
+ // SessionEnd hook runs under a 6-second watchdog, so asking it to fetch
250
+ // /me would trade a coaching metric for dropped uploads.
251
+ delete metrics.accessibility; // never trusted from the client
252
+ const settings = seams.resolveOptional('builder-settings');
253
+ let interactionProfile = null;
254
+ let profileUnavailable = false;
255
+ if (settings && settings.getBuilderBehaviorPrefs && settings.describeInteraction) {
256
+ // Best-effort: a coaching score must never be the reason an upload 500s.
257
+ // The failure is RECORDED ON THE ROW rather than logged — a durable flag
258
+ // a reader of the score can see beats a console line nobody correlates
259
+ // with the session it degraded.
260
+ try {
261
+ const prefs = await settings.getBuilderBehaviorPrefs(req.builder.id);
262
+ interactionProfile = settings.describeInteraction(prefs).effective;
263
+ } catch (_) {
264
+ profileUnavailable = true;
265
+ }
266
+ }
267
+ // With the module off (or the read failed) the scorer falls back to the
268
+ // accessible-leaning DEFAULT profile, which is the right posture: an
269
+ // instance that never configured a profile has every builder on it.
270
+ metrics.accessibility = {
271
+ ...scoreAccessibility(detailArr, { profile: interactionProfile }),
272
+ ...(profileUnavailable ? { profile_unavailable: true } : {}),
273
+ };
240
274
  const modelUsage = body.model_usage && typeof body.model_usage === 'object'
241
275
  ? body.model_usage : {};
242
276
  // upsert + cost-plus token reward in ONE transaction (task 1005). The