@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.
- package/.bongos-core.json +59 -24
- package/docs/adr/0291-the-accessibility-dimension-scores-the-transcript-not-the-diff.md +35 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -14
- package/docs/copy-registry.json +46 -19
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/sessions.html +3 -1
- package/modules/hall-ui/public/sessions.js +33 -1
- package/modules/lifecycle/github-push.js +70 -15
- package/modules/sessions/accessibility.js +373 -0
- package/modules/sessions/db.js +11 -0
- package/modules/sessions/routes/sessions.js +35 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/artifact-audit.js +309 -0
- package/scripts/gds/session-eval.js +54 -4
- package/src/module-api.js +1 -1
- package/tests/artifact_audit.mjs +210 -0
- package/tests/auto_merge_diagnosis.mjs +313 -0
- package/tests/hall_roster_world.mjs +6 -2
- package/tests/hall_sessions_ledger.mjs +94 -0
- package/tests/session_accessibility.mjs +243 -0
- package/tests/session_eval.mjs +52 -1
|
@@ -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="
|
|
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
|
|
530
|
-
if (!nodeId) return
|
|
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
|
|
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
|
|
569
|
+
return fail(scrubCredential(msg || 'GitHub GraphQL rejected the mutation').slice(0, 300));
|
|
546
570
|
} catch (e) {
|
|
547
|
-
return
|
|
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',
|
|
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',
|
|
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)
|
|
1395
|
-
|
|
1396
|
-
|
|
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 {
|
|
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
|
+
};
|
package/modules/sessions/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|