@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.
- package/.bongos-core.json +53 -28
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +30 -2
- package/migrations/core_249_session_token_hash.sql +103 -0
- package/modules/grading/grader-rubric.json +17 -7
- package/modules/grading/grader-score.js +7 -1
- package/modules/grading/grader-workers/extract-json.js +7 -1
- package/modules/grading/grader-workers/index.js +43 -0
- package/modules/grading/grader-workers/reader.js +218 -0
- package/modules/grading/grader.js +47 -11
- package/modules/lifecycle/cascade.js +23 -13
- package/modules/lifecycle/tests/cascade.mjs +18 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/db-backup-offsite.sh +105 -9
- package/scripts/gds/run-unit-tests.js +8 -0
- package/scripts/gds/ship-grade.js +28 -2
- package/src/bongos/db-kernel.js +42 -6
- package/src/bongos/db.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/auth_session_expiry_db.mjs +26 -21
- package/tests/extract_json.mjs +8 -0
- package/tests/government_live_proof.mjs +8 -2
- package/tests/grader_reader_lens.mjs +257 -0
- package/tests/own_session_revoke.mjs +13 -1
- package/tests/session_token_hash.mjs +139 -0
- package/tests/session_token_hash_db.mjs +193 -0
|
@@ -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
|
-
|
|
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 =
|
|
135
|
-
const concernsCount =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
192
|
-
const gating = nonAdvisory.length ? nonAdvisory :
|
|
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 =
|
|
232
|
-
const advisoryConcerns =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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 —
|
|
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)
|
|
217
|
-
// "REVIEW THE GRADER … coach it", which asked for something no
|
|
218
|
-
// do: the panel's four lenses read completion, code quality,
|
|
219
|
-
// runtime cost, so there was no copy verdict to coach.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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="$
|
|
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
|
|
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"
|