@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.
@@ -0,0 +1,218 @@
1
+ // The Reader — the advisory copy lens (task 1003977, ADR 0298).
2
+ //
3
+ // Specialty: the SENTENCES. Is a line a person reads true, clear, in the
4
+ // project's voice, and worth their attention? The other four workers read
5
+ // completion (Narc), code quality (Quality), security (Hacker) and runtime cost
6
+ // (Efficiency); not one of them reads a sentence. Task 1003937 established that
7
+ // across four consecutive artist reviews, all of which found the same wall and
8
+ // all of whose graded ships deservedly passed on the axes that WERE measured.
9
+ //
10
+ // ADVISORY, FULL STOP — and that is structural here, not a filter elsewhere.
11
+ // This worker's computeVerdict cannot return 'fail' or 'concerns'; it returns
12
+ // the single verdict 'advisory' on every path, including its own errors. The
13
+ // panel then excludes it from the gate (NON_GATING_WORKERS in index.js, and the
14
+ // partition in grader.js aggregatePanel). Two independent mechanisms, because
15
+ // the owner's term (2026-09-17) is absolute: ADR 0241 put the artist's judgement
16
+ // on the DEPLOY and off the ship path, after ADR 0162 retired review gates for
17
+ // stranding graded-PASS work. A reader that could fail a ship puts that back.
18
+ //
19
+ // NOTE THIS IS STRONGER THAN ADVISORY_WORKERS. The Narc is "advisory" in the
20
+ // sense that a Narc-only fail DOWNGRADES to concerns (manual-confirm). The
21
+ // Reader cannot reach concerns either. There is deliberately NO blocker escape
22
+ // hatch of the kind narc/hacker carry ("advisory ONLY if it carries no
23
+ // blocker"): a copy lens finding something that genuinely blocks a ship is a
24
+ // finding in another lens's territory, and the escape hatch would be the exact
25
+ // hole the owner's term closes.
26
+ //
27
+ // THE STANDARD IS WRITTEN DOWN. docs/voice.md (task 1003976) is the rubric, and
28
+ // its own rule is that a finding citing no rule id is not a finding. The guide
29
+ // splits HARD rules (V1-V5, checkable — a lens may assert a break) from
30
+ // JUDGEMENT rules (V6-V11, recognisable but not provable — a lens may only FLAG
31
+ // a candidate and must quote the line). That split is reproduced in the prompt
32
+ // below, because it is what keeps an advisory lens from recording taste as fact.
33
+
34
+ const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
35
+ const { parseCommon } = require('./worker-verdict');
36
+ const { extractJson } = require('./extract-json');
37
+
38
+ const KIND = 'reader';
39
+ const NAME = 'Reader';
40
+ const SPECIALTY = 'user-facing copy: truth, clarity, voice (docs/voice.md), against the written guide';
41
+
42
+ // The ONE verdict this worker emits. Not 'pass': a pass is a claim the gate may
43
+ // read, and an errored Reader claiming 'pass' would be a false one. Registered
44
+ // in grader-score.js WORKER_VERDICTS so hasPanelEvidence still recognises a
45
+ // panel that carries a Reader row.
46
+ const ADVISORY_VERDICT = 'advisory';
47
+
48
+ const PROMPT_HEADER = `You are the **Reader** — the copy lens for the ${LLM_PROJECT} project. You read the SENTENCES a person will see.
49
+
50
+ You are ADVISORY. Nothing you report can block this ship, and you should not write as though it could. Your output is coaching for the artist who reviews this work afterwards, and a record of where the grade was otherwise blind.
51
+
52
+ Your ONLY job is user-facing copy. You do NOT score whether the description was implemented (Narc), whether the code is well-written (Quality), whether it is secure (Hacker), or what it costs to run (Efficiency). If you notice something in those territories, say so in \`notes_md\` and move on — do not file it as an issue.
53
+
54
+ ## The standard is written down, and you must cite it
55
+
56
+ \`docs/voice.md\` is the voice guide. **Read it first** — it is the rubric for this grade, and its rules carry ids (V1, V2, …).
57
+
58
+ **A finding that cites no rule id is not a finding.** If a line is bad and no rule in the guide reaches it, say that in \`notes_md\` as a gap in the guide. Do not invent a rule at grade time. This is the guide's own instruction and it is what separates a lens from a second model's taste.
59
+
60
+ The guide splits its rules in two, and the split governs what you may say:
61
+
62
+ - **Hard rules (V1-V5)** can be checked. A line either breaks one or it does not. You MAY assert a break.
63
+ - **Judgement rules (V6-V11)** name a failure a person can recognise but a checker cannot prove. You may **flag a candidate** and must quote the line and the rule id. You may NEVER assert that the line is wrong. The artist decides.
64
+
65
+ Set \`kind\` on every issue to \`"break"\` (hard rule, asserted) or \`"flag"\` (judgement rule, offered). A judgement-rule issue filed as a break is the single most damaging thing you can do here, because it records taste as fact.
66
+
67
+ ## What counts as user-facing copy
68
+
69
+ What the guide governs: every string a person reads on the builders' hall, the status dashboard and the landing surface; server-side route failures and validation text; outbound broadcast text.
70
+
71
+ What it does NOT govern, and you must not score: repository documentation, ADRs, session logs, code comments, commit messages, skill prose. Those are written for builders and agents in ordinary technical prose. A diff that changes ONLY those carries no copy for you to read — say so and score 10.
72
+
73
+ ## How you work
74
+
75
+ 1. Read \`docs/voice.md\`.
76
+ 2. Walk the diff for CHANGED or ADDED user-facing strings. Quote each line you judge.
77
+ 3. For each, name the rule id it breaks or that it is a candidate against.
78
+ 4. Score \`copy_score\` (0-10):
79
+ - 10 = no user-facing copy changed, or every changed line is clean against the guide.
80
+ - 8 = clean; one or two judgement-rule flags worth the artist's eye.
81
+ - 6 = at least one hard-rule break in a line a person will read.
82
+ - 4 = several hard-rule breaks, or one in a high-traffic surface.
83
+ - 2 = the copy actively misleads (a word that means something else here, a claim the code does not support).
84
+ - 0 = the copy is wrong in a way that would embarrass the project in front of a user.
85
+
86
+ The score is a RECORD, not a gate. Nothing happens to the ship at any value.
87
+
88
+ 5. Severities: \`major\` for a hard-rule break on a line a person reads; \`minor\` for a hard-rule break somewhere quiet; \`nit\` for a judgement-rule flag. Never \`blocker\` — you cannot block, and a blocker severity from this lens is a category error.
89
+
90
+ ## Read budget
91
+
92
+ **5 Read calls.** Spend the first on \`docs/voice.md\`. Spend the rest confirming that a string you are judging really is user-facing — the surrounding render path tells you, and a string in a code comment is not copy.
93
+
94
+ ## Sanity checks
95
+
96
+ - A diff with no user-facing string changes scores 10 with no issues. That is a normal, common outcome. Do not manufacture findings to look useful.
97
+ - Every issue quotes the line and names a rule id.
98
+ - No issue carries severity \`blocker\`.
99
+ - Do not rewrite the copy for them unless the fix is obvious and one line; the artist owns the remake.
100
+
101
+ ## Return ONLY JSON
102
+
103
+ No prose, no markdown fences:
104
+
105
+ \`\`\`
106
+ {
107
+ "copy_score": <number 0-10>,
108
+ "confidence": <number 0-1>,
109
+ "reads_used": <integer 0-5>,
110
+ "strings_reviewed": [
111
+ { "where": "<file:line>", "line": "<the string, quoted>", "verdict": "ok"|"break"|"flag", "rule": "<V-id or null>" }
112
+ ],
113
+ "issues": [
114
+ { "severity": "major"|"minor"|"nit", "kind": "break"|"flag", "rule": "<V-id>", "file": "<path>", "summary": "<one line, quotes the copy and names the rule>" }
115
+ ],
116
+ "notes_md": "<short markdown — which surfaces carried copy, what you could not judge, and any gap in the guide you hit>"
117
+ }
118
+ \`\`\`
119
+
120
+ You have a Read tool with a hard budget of 5 calls. Do not run tests, do not edit files, do not call the network.`;
121
+
122
+ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound, contextBundle }) {
123
+ return buildWorkerPrompt({
124
+ priorRound,
125
+ contextBundle,
126
+ header: PROMPT_HEADER,
127
+ task: task || {},
128
+ valueSummary,
129
+ diff,
130
+ changedFiles,
131
+ taskHeader: '## Task (context — you judge the copy, not the completion)',
132
+ descLabel: '**Description:**',
133
+ emptyFilesText: '(no files changed)',
134
+ readFilesHint: 'You can Read any of these files to see their post-merge state (5-call budget — spend the first on docs/voice.md).',
135
+ finalInstruction: 'Now read the copy. Cite a rule id on every finding, or do not file it. Output ONLY the JSON object.',
136
+ });
137
+ }
138
+
139
+ // The Reader's verdict is 'advisory' on EVERY path — a clean read, a bad read,
140
+ // a crashed subagent, a timeout. There is no branch that returns anything else,
141
+ // which is what makes "it cannot block" a property of this function rather than
142
+ // a promise kept by its callers.
143
+ //
144
+ // An error still travels: `error` is set, the notes say so, and copy_score is
145
+ // null rather than 0. A 0 would be a real score meaning "the copy is wrong in a
146
+ // way that would embarrass the project", and an outage is not that. The same
147
+ // distinction #943 draws for the panel — missing signal is not a bad signal.
148
+ function computeVerdict(parsed, meta = {}) {
149
+ const advisory = (extra) => ({
150
+ verdict: ADVISORY_VERDICT,
151
+ scores: {},
152
+ issues: [],
153
+ confidence: null,
154
+ error: null,
155
+ ...extra,
156
+ });
157
+
158
+ if (meta.subprocess_error) {
159
+ return advisory({
160
+ notes_md: `Reader — ADVISORY, no signal: subagent error (${meta.subprocess_error}). The copy was not read; this does not affect the ship.`,
161
+ error: meta.subprocess_error,
162
+ });
163
+ }
164
+ if (!parsed || typeof parsed !== 'object') {
165
+ return advisory({
166
+ notes_md: 'Reader — ADVISORY, no signal: could not parse subagent output as JSON. The copy was not read; this does not affect the ship.',
167
+ error: 'parse_error',
168
+ });
169
+ }
170
+
171
+ const { conf, issues: rawIssues, notesFromSubagent } = parseCommon(parsed);
172
+ const score = Number(parsed.copy_score);
173
+ const reviewed = Array.isArray(parsed.strings_reviewed) ? parsed.strings_reviewed : [];
174
+
175
+ // A blocker from this lens is a category error, not a blocking finding. It is
176
+ // FLOORED to major rather than dropped: the observation may be real and the
177
+ // artist should see it; what it may not be is a severity the gate reads as
178
+ // blocking. Dropping it would hide a finding; honouring it would hand this
179
+ // worker the veto the owner's term denies it.
180
+ const issues = rawIssues.map((i) => (
181
+ i && i.severity === 'blocker'
182
+ ? { ...i, severity: 'major', severity_floored_from: 'blocker' }
183
+ : i
184
+ ));
185
+
186
+ if (!Number.isFinite(score) || score < 0 || score > 10) {
187
+ return advisory({
188
+ issues,
189
+ notes_md: `Reader — ADVISORY, no score: copy_score was non-numeric or out of range (got ${parsed.copy_score}). Findings below still stand.`,
190
+ error: 'schema_error',
191
+ confidence: conf,
192
+ });
193
+ }
194
+
195
+ const s = Math.round(score * 10) / 10;
196
+ const breaks = issues.filter((i) => i.kind === 'break').length;
197
+ const flags = issues.filter((i) => i.kind === 'flag').length;
198
+ const prefix = `Reader — ADVISORY (copy=${s}/10, ${breaks} rule break${breaks === 1 ? '' : 's'}, ${flags} flag${flags === 1 ? '' : 's'}, ${reviewed.length} string${reviewed.length === 1 ? '' : 's'} reviewed, conf=${conf ?? '?'})`;
199
+
200
+ return advisory({
201
+ scores: { copy_score: s },
202
+ issues,
203
+ strings_reviewed: reviewed,
204
+ notes_md: `${prefix}\n${notesFromSubagent.trim()}`.trim(),
205
+ confidence: conf,
206
+ });
207
+ }
208
+
209
+ module.exports = {
210
+ KIND,
211
+ NAME,
212
+ SPECIALTY,
213
+ ADVISORY_VERDICT,
214
+ PROMPT_HEADER,
215
+ buildPrompt,
216
+ extractJson,
217
+ computeVerdict,
218
+ };
@@ -110,7 +110,27 @@ function describeOutage(gating) {
110
110
  }
111
111
 
112
112
  function aggregatePanel(workerResults, meta = {}) {
113
- const N = workerResults.length;
113
+ // task 1003977 — THE NON-GATING PARTITION, before any gate arithmetic.
114
+ //
115
+ // A non-gating worker (today: the Reader, the advisory copy lens) contributes
116
+ // its scores and issues to the RECORD and nothing at all to the GATE. It is
117
+ // removed here, once, rather than special-cased in each of the counts below —
118
+ // pass/concerns/fail counts, the error and outage detection, the confidence
119
+ // median and every branch of the verdict all read `gateResults`.
120
+ //
121
+ // Removing it from the counts is why a Reader outage cannot reach #943's
122
+ // "every gating worker is missing signal" branch, and why a Reader cannot
123
+ // nudge the confidence median that drives the server-side gate. `N` stays the
124
+ // GATE's denominator so the verdict strings keep meaning what they said
125
+ // before ("2/4 gating worker(s) failed" counts the four that can fail).
126
+ //
127
+ // ADVISORY_WORKERS below is a DIFFERENT, weaker thing: narc is advisory in
128
+ // that a narc-only fail downgrades to manual-confirm. A non-gating worker
129
+ // never reaches even that.
130
+ const isNonGating = (w) => w && workers.isNonGatingKind(w.kind);
131
+ const nonGatingResults = workerResults.filter(isNonGating);
132
+ const gateResults = workerResults.filter((w) => !isNonGating(w));
133
+ const N = gateResults.length;
114
134
 
115
135
  // Score axes — median across workers that reported each. Reuses the
116
136
  // module-level median() helper (rounded to one decimal).
@@ -124,6 +144,12 @@ function aggregatePanel(workerResults, meta = {}) {
124
144
  // Phase 2.2 (#421): Efficiency Monger reports efficiency_score. Same
125
145
  // additive treatment as security_score.
126
146
  const efficiencyScore = median(workerResults.map((w) => w.scores?.efficiency_score));
147
+ // task 1003977: the Reader reports copy_score. Recorded like security_score
148
+ // and efficiency_score before it — additive, and read from the FULL result
149
+ // list rather than gateResults, because the whole point is that a non-gating
150
+ // worker's SIGNAL lands in the record even though its verdict never reaches
151
+ // the gate. Null whenever no Reader ran, which is every non-copy-bearing ship.
152
+ const copyScore = median(workerResults.map((w) => w.scores?.copy_score));
127
153
 
128
154
  const economyPenalty =
129
155
  economy != null ? Math.max(0, (THRESHOLD - economy) / 2) : 0;
@@ -131,8 +157,8 @@ function aggregatePanel(workerResults, meta = {}) {
131
157
  const blended = ff != null && cq != null ? r1((ff + cq) / 2) : 0;
132
158
 
133
159
  // Verdict counts.
134
- const passCount = workerResults.filter((w) => w.verdict === 'pass').length;
135
- const concernsCount = workerResults.filter((w) => w.verdict === 'concerns').length;
160
+ const passCount = gateResults.filter((w) => w.verdict === 'pass').length;
161
+ const concernsCount = gateResults.filter((w) => w.verdict === 'concerns').length;
136
162
  // A crashed worker sets verdict='fail' as a DEFAULT in its computeVerdict()
137
163
  // subprocess_error / parse_error / schema_error early returns (see each
138
164
  // worker module) — that is MISSING SIGNAL, not a substantive finding, and
@@ -149,12 +175,12 @@ function aggregatePanel(workerResults, meta = {}) {
149
175
  const hasSubstantiveIssue = (w) =>
150
176
  (w.issues || []).some((i) => i.severity === 'blocker' || i.severity === 'major');
151
177
  const isMissingSignal = (w) => !!w.error && !hasSubstantiveIssue(w);
152
- const failCount = workerResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
178
+ const failCount = gateResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
153
179
  // NOTE (#473/#589): signals.error_count counts only isMissingSignal workers — i.e. a worker that
154
180
  // errored AND surfaced no blocking issue. A worker that errored but still raised a blocker/major
155
181
  // is a real finding, so it counts in fail_count (above), NOT here. This is a deliberate semantic
156
182
  // change from the old "all w.error" meaning; keep error_count = missing-signal-only.
157
- const errorCount = workerResults.filter((w) => isMissingSignal(w)).length;
183
+ const errorCount = gateResults.filter((w) => isMissingSignal(w)).length;
158
184
 
159
185
  // V3.R85 (#481): the Narc (functional_fidelity) is ADVISORY in the gate —
160
186
  // Lars's call 2026-05-30 after #303, where over-literal acceptance-criteria
@@ -188,8 +214,8 @@ function aggregatePanel(workerResults, meta = {}) {
188
214
  // taken out by a grader outage, so it is not evidence either way about whether the
189
215
  // panel ran. Task 1003574.
190
216
  const isDeterministic = (w) => w && w.model === 'deterministic';
191
- const nonAdvisory = workerResults.filter((w) => !isAdvisory(w));
192
- const gating = nonAdvisory.length ? nonAdvisory : workerResults;
217
+ const nonAdvisory = gateResults.filter((w) => !isAdvisory(w));
218
+ const gating = nonAdvisory.length ? nonAdvisory : gateResults;
193
219
  const gatingFail = gating.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
194
220
  const gatingError = gating.filter((w) => isMissingSignal(w)).length;
195
221
  const gatingConcerns = gating.filter((w) => w.verdict === 'concerns').length;
@@ -228,15 +254,15 @@ function aggregatePanel(workerResults, meta = {}) {
228
254
  const gatingErrorAll = subagentGating.length > 0 && subagentGating.every((w) => isMissingSignal(w));
229
255
  // Advisory-worker outcomes — never a hard block. Uses isAdvisory so a Narc
230
256
  // that DID raise a blocker is treated as gating (in `nonAdvisory`), not here.
231
- const advisoryFail = workerResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
232
- const advisoryConcerns = workerResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
257
+ const advisoryFail = gateResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
258
+ const advisoryConcerns = gateResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
233
259
 
234
260
  // Confidence — median for pass, fallthrough for concerns, 0 for fail.
235
261
  // (Drives the server-side gate; see file header.) Computed UNROUNDED —
236
262
  // confidence sits near the 0.4 gate cap so the module-level median()
237
263
  // helper (which rounds via r1()) would risk nudging a borderline value
238
264
  // across the boundary. Inlined for that one-decimal-precision reason.
239
- const confVals = workerResults
265
+ const confVals = gateResults
240
266
  .map((w) => w.confidence)
241
267
  .filter((n) => Number.isFinite(n))
242
268
  .slice()
@@ -360,7 +386,12 @@ function aggregatePanel(workerResults, meta = {}) {
360
386
 
361
387
  // Build display notes.
362
388
  const lines = [];
363
- lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${workerResults.map((w) => w.kind).join(', ')}) — ${verdict}`);
389
+ // N counts the GATING workers; a non-gating one is named separately so the
390
+ // line cannot read as an off-by-one ("panel of 4: …five kinds…").
391
+ const advisoryKinds = nonGatingResults.map((w) => w.kind);
392
+ lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${gateResults.map((w) => w.kind).join(', ')}`
393
+ + (advisoryKinds.length ? ` · advisory, non-gating: ${advisoryKinds.join(', ')}` : '')
394
+ + `) — ${verdict}`);
364
395
  if (ff != null) lines.push(` functional_fidelity (Narc): ${ff}/10`);
365
396
  if (cq != null) lines.push(` code_quality (Quality): ${cq}/10`);
366
397
  if (economy != null) lines.push(` economy (Quality, adv.): ${economy}/10`);
@@ -421,6 +452,7 @@ function aggregatePanel(workerResults, meta = {}) {
421
452
  code_quality_effective: cqEffective,
422
453
  security_score: security,
423
454
  efficiency_score: efficiencyScore,
455
+ copy_score: copyScore,
424
456
  },
425
457
  signals: {
426
458
  grader_kind: 'subagent-panel',
@@ -470,6 +502,10 @@ function graderUnavailableResult({ reason, note, meta = {} } = {}) {
470
502
  code_quality_effective: null,
471
503
  security_score: null,
472
504
  efficiency_score: null,
505
+ // task 1003977: nulled with the rest so an outage grade's rubric_scores
506
+ // has the SAME SHAPE as a normal one. A key that is absent here and
507
+ // present there is a difference a consumer has to know about.
508
+ copy_score: null,
473
509
  },
474
510
  signals: {
475
511
  grader_kind: 'subagent-panel',
@@ -135,23 +135,33 @@ function hasCopyOrVisualWork(task) {
135
135
  // STEP 2 remakes the copy/visuals with both routes explicitly offered — R04's
136
136
  // brief, verbatim in intent.
137
137
  //
138
- // STEP 1 used to say "coach the grader", and for as long as that was the wording
139
- // it asked for something no artist could do (task 1003937, ADR 0298). The panel's
140
- // four lenses — narc, quality, hacker, efficiency — read completion, code quality,
141
- // security and runtime cost; not one of them reads a sentence, so there was no
142
- // counterpart to coach and four consecutive reviews found the same wall. The
143
- // ADR's decision is a fifth ADVISORY 'reader' lens, scoped to copy-bearing diffs
144
- // by this file's own hasCopyOrVisualWork, built only after the instance writes a
145
- // voice guide to judge against. Until it exists the step asks for the RECORD
146
- // instead of the coaching: followable today, and it is the corpus both the guide
147
- // and the lens will be written from. When the reader lands, this reverts to
148
- // coaching a lens that is actually listening — and the wording is the signal for
149
- // which of those two worlds we are in, so do not restore the old sentence early.
138
+ // STEP 1 ASKS FOR COACHING AGAIN, because there is now a lens listening.
139
+ //
140
+ // The history, because the wording is load-bearing: STEP 1 originally said
141
+ // "coach the grader", which asked for something no artist could do (task
142
+ // 1003937, ADR 0298) — the panel's four lenses read completion, code quality,
143
+ // security and runtime cost, and not one read a sentence, so four consecutive
144
+ // reviews found the same wall. The step was rewritten to ask for the RECORD
145
+ // instead, and that version carried an explicit instruction not to restore the
146
+ // coaching wording early, because the wording is the signal for which world we
147
+ // are in.
148
+ //
149
+ // Task 1003977 shipped the fifth lens: an ADVISORY 'reader', scoped to
150
+ // copy-bearing diffs by THIS FILE'S hasCopyOrVisualWork — the same predicate
151
+ // that fires this cascade, so an artist review and the copy lens cover exactly
152
+ // the same set by construction, and widening one widens both. The voice guide
153
+ // it waited on is docs/voice.md (task 1003976). So there is a counterpart to
154
+ // coach, it has a written standard to be coached against, and the record the
155
+ // interim wording collected is what both were built from.
156
+ //
157
+ // What has NOT changed: the reader is non-gating and cannot hold a ship (ADR
158
+ // 0241 keeps the artist's judgement on the deploy), so this review is still a
159
+ // request triage may dismiss.
150
160
  function artistReviewBody(task) {
151
161
  return [
152
162
  `This is an ARTIST REVIEW, generated automatically because task ${task.id} put copy or visuals in front of a person. It is a REQUEST, not a gate — it blocks nothing that shipped, and triage may dismiss it.`,
153
163
  '',
154
- `STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade), then record what an artist would have caught and no lens did: slop, an off-voice line, a claim that does not land, a visual that reads as generated. Do not expect to find a copy verdict to argue with — the panel's four lenses read completion, code quality, security and runtime cost, and none of them reads a sentence (ADR 0298). Your record IS the compounding part: it is the evidence the advisory 'reader' lens is being built from, and the corpus the voice guide it waits on will be written against.`,
164
+ `STEP 1 — COACH THE READER. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade) and find the 'reader' row in signals.workers, with its copy_score in rubric_scores. That lens read the changed copy against docs/voice.md and cited rule ids. Say where it was RIGHT, where it was WRONG, and — the valuable one — what it did not see at all. A finding it filed that you disagree with is worth as much as one it missed: both are corrections to a lens that is listening, which is what this step could not ask for before task 1003977 shipped it (ADR 0298). If the miss is because no rule in docs/voice.md reaches it, that is a gap in the GUIDE — file it against the guide rather than expecting the lens to invent a rule at grade time. Two cases where there will be no reader row and that is not a fault: a ship that took the trivial-diff path (a single generalist shot, no panel), and one where the lens errored — it is advisory, so an outage is recorded and nothing is blocked.`,
155
165
  '',
156
166
  'STEP 2 — REMAKE OR TWEAK the copy/visuals if they need it. Your choice of route, both offered on purpose: OUTSIDE the AI chat (edit the surface directly and land it through the copy desk or the normal pipeline), or WITH it (draft in chat, then land). Either way it lands as a claimed, graded, shipped task like everything else — there is no live CMS (goal 1000074\'s hard rule).',
157
167
  '',
@@ -213,15 +213,24 @@ await test('a copy/visual ship generates the two-step artist review', () => {
213
213
  assert.match(spec.title, /7001/);
214
214
  // Both steps, and both routes of step 2, are in the body.
215
215
  //
216
- // STEP 1's label tracks ADR 0298 (task 1003937). It used to read
217
- // "REVIEW THE GRADER … coach it", which asked for something no artist could
218
- // do: the panel's four lenses read completion, code quality, security and
219
- // runtime cost, so there was no copy verdict to coach. Until the advisory
220
- // reader worker ships (task 1003977, itself gated on the voice guide in
221
- // task 1003976) the step asks for the RECORD instead — hence the pin on the
222
- // new label AND on the ADR reference, which is what stops a well-meaning
223
- // copy-edit from restoring the unfollowable version without reading why.
224
- assert.match(spec.description, /STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE/);
216
+ // STEP 1's label tracks ADR 0298 (task 1003937), and it has now turned twice.
217
+ // It first read "REVIEW THE GRADER … coach it", which asked for something no
218
+ // artist could do: the panel's four lenses read completion, code quality,
219
+ // security and runtime cost, so there was no copy verdict to coach. It was
220
+ // rewritten to ask for the RECORD instead, as an explicitly INTERIM wording,
221
+ // pinned here so a well-meaning copy-edit could not restore the unfollowable
222
+ // version without reading why.
223
+ //
224
+ // Task 1003977 shipped the advisory reader lens and the voice guide it waited
225
+ // on (task 1003976), so the step is coaching again — and this pin moves with
226
+ // it. The two must turn TOGETHER: the wording is the signal for which world
227
+ // we are in, so a body that still said "no lens reads a sentence" after the
228
+ // lens landed would send every artist looking for a counterpart that is
229
+ // sitting right there in the grade record.
230
+ assert.match(spec.description, /STEP 1 — COACH THE READER/);
231
+ assert.match(spec.description, /docs\/voice\.md/, 'and names the written standard to coach against');
232
+ assert.doesNotMatch(spec.description, /none of them reads a sentence/,
233
+ 'the interim claim is gone — it stopped being true when the reader shipped');
225
234
  assert.match(spec.description, /ADR 0298/, 'the step names the decision that shaped it');
226
235
  assert.match(spec.description, /STEP 2 — REMAKE OR TWEAK/);
227
236
  assert.match(spec.description, /OUTSIDE the AI chat/);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.888",
3
+ "version": "1.19.890",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.888",
9
+ "version": "1.19.890",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.888",
3
+ "version": "1.19.890",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -17,6 +17,36 @@
17
17
  # ADR 0022. The systemd unit injects them via EnvironmentFile; a manual run
18
18
  # sources the same file. Required vars:
19
19
  # SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT
20
+ # GDS_OFFSITE_AGE_RECIPIENT
21
+ #
22
+ # ENCRYPTION IS MANDATORY (task 1003372, audit ref A1). Until that task this
23
+ # script uploaded the raw pg_dump to the Space. A database dump is not "a backup"
24
+ # from a security standpoint — it is every secret the platform holds, in one
25
+ # file, on someone else's disk: OAuth credentials, the GitHub App private key,
26
+ # and (before core_249) every builder's live session bearer in plaintext. A
27
+ # breach of the Space, a mis-set bucket ACL, or a leaked read-only S3 key was a
28
+ # total compromise of the instance with no trace in any log the platform keeps.
29
+ #
30
+ # The dump is encrypted to a PUBLIC key (`age`), so this box can produce backups
31
+ # it cannot itself read. That asymmetry is the point: an attacker who owns the
32
+ # droplet gets the live database anyway, but they do not also get the archive of
33
+ # every previous state, and they cannot decrypt what is already in the Space.
34
+ # Keep the private key OFF this machine.
35
+ #
36
+ # One-time setup, on a machine that is NOT the server:
37
+ # age-keygen -o bongos-backup.key # store this somewhere safe and offline
38
+ # # it prints: Public key: age1xxxxxxxx...
39
+ # Then on the server, add to $ENV_FILE:
40
+ # GDS_OFFSITE_AGE_RECIPIENT=age1xxxxxxxx...
41
+ #
42
+ # To restore:
43
+ # age -d -i bongos-backup.key <dump>.sql.gz.age | gunzip | psql <db>
44
+ #
45
+ # WITHOUT A RECIPIENT THIS SCRIPT REFUSES TO RUN. It does not fall back to a
46
+ # plaintext upload: that fallback is the vulnerability, and a silent one, since
47
+ # a backup that "worked" looks identical either way. A failing timer is visible
48
+ # in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is
49
+ # visible to nobody until it matters.
20
50
  #
21
51
  # Manual one-shot:
22
52
  # <instance-root>/scripts/gds/db-backup-offsite.sh
@@ -72,13 +102,17 @@ ENV_FILE="${GDS_SPACES_ENV:-${CONFIG_DIR_NAME:+/etc/$CONFIG_DIR_NAME/spaces.env}
72
102
  # extract only the five expected KEY=VALUE lines. `read -r k v` with IFS='='
73
103
  # splits on the FIRST '=' only, so base64 secrets containing '/', '+' or '='
74
104
  # survive intact.
75
- if [ -z "${SPACES_KEY:-}" ] && [ -f "$ENV_FILE" ]; then
105
+ # Read the file when EITHER the credentials or the encryption recipient is still
106
+ # unset. Keying this on SPACES_KEY alone would skip the file on a box where
107
+ # systemd injects the S3 vars but the recipient was added to the file later —
108
+ # and the script would then refuse to run while the key it needs sat right there.
109
+ if { [ -z "${SPACES_KEY:-}" ] || [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; } && [ -f "$ENV_FILE" ]; then
76
110
  while IFS='=' read -r _k _v; do
77
111
  case "$_k" in
78
- SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)
112
+ SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)
79
113
  export "$_k=$_v" ;;
80
114
  esac
81
- done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)=' "$ENV_FILE" || true)
115
+ done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)=' "$ENV_FILE" || true)
82
116
  fi
83
117
 
84
118
  missing=()
@@ -91,8 +125,30 @@ if [ "${#missing[@]}" -gt 0 ]; then
91
125
  exit 2
92
126
  fi
93
127
 
128
+ # The encryption recipient is as required as the credentials — a dump this box
129
+ # can upload but nobody can decrypt is useless, and a dump it uploads in the
130
+ # clear is the finding this refusal exists to close (task 1003372, audit A1).
131
+ if [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; then
132
+ echo "ERROR: GDS_OFFSITE_AGE_RECIPIENT is not set — refusing to ship an unencrypted database dump offsite." >&2
133
+ echo " A dump is every secret this platform holds, in one file, on storage the platform does not own." >&2
134
+ echo "" >&2
135
+ echo " Fix, on a machine that is NOT this server:" >&2
136
+ echo " age-keygen -o bongos-backup.key # keep this file safe and OFFLINE — it is the only way back" >&2
137
+ echo " then add the age1... public key it prints to $ENV_FILE:" >&2
138
+ echo " GDS_OFFSITE_AGE_RECIPIENT=age1..." >&2
139
+ echo "" >&2
140
+ echo " This is deliberately not optional: falling back to a plaintext upload would look identical in the logs." >&2
141
+ exit 2
142
+ fi
143
+
144
+
94
145
  # ---- preconditions ----------------------------------------------------------
95
146
 
147
+ if ! command -v age >/dev/null 2>&1; then
148
+ echo "ERROR: 'age' not on PATH — it is what encrypts the dump before it leaves this box." >&2
149
+ echo " Install with: apt install age (or fetch the static binary from https://github.com/FiloSottile/age/releases)" >&2
150
+ exit 2
151
+ fi
96
152
  if ! command -v python3 >/dev/null 2>&1; then
97
153
  echo "ERROR: python3 not on PATH (needed for the boto3 S3 client)." >&2
98
154
  exit 2
@@ -113,14 +169,38 @@ if [ -z "$LATEST" ]; then
113
169
  fi
114
170
 
115
171
  BASENAME=$(basename "$LATEST")
116
- KEY="${PREFIX}${BASENAME}"
117
172
  BYTES=$(stat -c '%s' "$LATEST")
118
173
 
119
- echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes) → s3://${SPACES_BUCKET}/${KEY}"
174
+ # ---- encrypt before it leaves the box ---------------------------------------
175
+ #
176
+ # To a temp file under the backup dir (same filesystem, so no cross-device copy
177
+ # of plaintext through /tmp) with a 0600 umask, and removed on every exit path
178
+ # including failure — the cleartext dump already lives beside it, so the
179
+ # ciphertext is not sensitive in itself, but leaving files behind on a failing
180
+ # weekly timer fills a disk nobody is watching.
181
+ umask 077
182
+ ENC="$(mktemp "${BACKUP_DIR}/.${BASENAME}.age.XXXXXX")"
183
+ trap 'rm -f "$ENC"' EXIT
184
+
185
+ if ! age -r "$GDS_OFFSITE_AGE_RECIPIENT" -o "$ENC" "$LATEST"; then
186
+ echo "ERROR: age refused to encrypt — is GDS_OFFSITE_AGE_RECIPIENT a valid age1... public key?" >&2
187
+ exit 2
188
+ fi
189
+
190
+ # A zero-byte or missing ciphertext would upload happily and restore to nothing.
191
+ # Checked here rather than trusted, because the failure is silent for a month.
192
+ ENC_BYTES=$(stat -c '%s' "$ENC")
193
+ if [ "$ENC_BYTES" -lt 1 ]; then
194
+ echo "ERROR: encryption produced an empty file — refusing to upload it as a backup." >&2
195
+ exit 2
196
+ fi
197
+
198
+ KEY="${PREFIX}${BASENAME}.age"
199
+ echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes → ${ENC_BYTES} encrypted) → s3://${SPACES_BUCKET}/${KEY}"
120
200
 
121
201
  # ---- upload + prune (single boto3 session) ----------------------------------
122
202
 
123
- GDS_OFFSITE_LOCAL="$LATEST" \
203
+ GDS_OFFSITE_LOCAL="$ENC" \
124
204
  GDS_OFFSITE_KEY="$KEY" \
125
205
  GDS_OFFSITE_KEEP="$KEEP" \
126
206
  GDS_OFFSITE_PREFIX="$PREFIX" \
@@ -151,10 +231,25 @@ s3 = boto3.client(
151
231
  s3.upload_file(local, bucket, objkey)
152
232
  print(f"[gds-db-offsite] uploaded {objkey}")
153
233
 
154
- # Prune: keep the newest `keep` objects under our prefix, delete the rest.
155
234
  resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
235
+ contents = resp.get("Contents", [])
236
+
237
+ # PURGE THE PLAINTEXT ALREADY UP THERE (task 1003372, audit A1). Every `.sql.gz`
238
+ # under this prefix was uploaded unencrypted by an earlier version of this
239
+ # script, and each one is a live copy of every secret the platform held on the
240
+ # day it was taken — including, before core_249, directly replayable session
241
+ # bearers. Encrypting from now on does nothing about them. They are deleted
242
+ # OUTSIDE the retention count, and unconditionally: retaining four of them
243
+ # "for safety" would retain the finding. The local nightly dumps on the droplet
244
+ # are untouched, so this removes copies, never the last copy.
245
+ legacy = [o for o in contents if o["Key"].endswith(".sql.gz")]
246
+ for obj in legacy:
247
+ s3.delete_object(Bucket=bucket, Key=obj["Key"])
248
+ print(f"[gds-db-offsite] PURGED unencrypted legacy dump {obj['Key']}")
249
+
250
+ # Prune: keep the newest `keep` ENCRYPTED objects under our prefix.
156
251
  objects = sorted(
157
- [o for o in resp.get("Contents", []) if o["Key"].endswith(".sql.gz")],
252
+ [o for o in contents if o["Key"].endswith(".sql.gz.age")],
158
253
  key=lambda o: o["LastModified"],
159
254
  )
160
255
  to_delete = objects[:-keep] if len(objects) > keep else []
@@ -163,7 +258,8 @@ for obj in to_delete:
163
258
  print(f"[gds-db-offsite] pruned {obj['Key']}")
164
259
 
165
260
  kept = min(len(objects), keep)
166
- print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}")
261
+ print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}"
262
+ + (f" · purged {len(legacy)} unencrypted legacy dump(s)" if legacy else ""))
167
263
  PYEOF
168
264
 
169
265
  echo "[$(date -u +%FT%TZ)] gds-db-offsite: done"