@bongos/core 1.19.920 → 1.19.922
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 +71 -31
- package/.claude/skills/grade-audit/SKILL.md +9 -0
- package/docs/adr/0320-an-empty-diff-is-not-an-ungraded-diff.md +159 -0
- package/docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md +163 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/idea-brightness.js +212 -0
- package/modules/economy/idea-size-historical.js +22 -3
- package/modules/economy/idea-size.js +30 -3
- package/modules/grading/grader-no-artifact.js +347 -0
- package/modules/grading/grader.js +6 -0
- package/modules/lifecycle/cascade.js +36 -0
- package/modules/lifecycle/migrations/lifecycle_010_removal_targets.sql +116 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/removal-targets.js +217 -0
- package/modules/lifecycle/routes/lifecycle.js +9 -0
- package/modules/lifecycle/routes/removal-proposals.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +26 -2
- package/scripts/gds/run-unit-tests.js +17 -1
- package/scripts/gds/ship-flow.js +54 -7
- package/scripts/gds/ship-grade-steps.js +43 -5
- package/scripts/gds/ship-grade.js +36 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +56 -0
- package/tests/git_merge_regen.mjs +87 -3
- package/tests/grader_no_artifact.mjs +360 -0
- package/tests/grader_prompt_fencing.mjs +46 -0
- package/tests/idea_brightness.mjs +550 -0
- package/tests/no_artifact_escalation.mjs +13 -3
- package/tests/skip_is_not_pass.mjs +91 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/removal-targets.js — WHOSE WORK a removal proposal is about
|
|
4
|
+
// (task 1004099 / BV2.R16 brightness, goal 1000095; ADR 0321, under ADR 0313 +
|
|
5
|
+
// ADR 0319).
|
|
6
|
+
//
|
|
7
|
+
// THE ONE FACT THIS FILE CARRIES. lifecycle_009 stores the VERDICT on a removal
|
|
8
|
+
// proposal and the SHIP that deleted the files, and says outright that the list
|
|
9
|
+
// of tasks whose work was removed is "deliberately not here — brightness designs
|
|
10
|
+
// its own storage for targets when it needs one". Brightness needs one, because
|
|
11
|
+
// the direction it reads is the opposite of the one the verdict row holds: it
|
|
12
|
+
// asks "which previously-shipped tasks stopped counting", and `ship_task_id`
|
|
13
|
+
// answers "which ship did the deleting". Those are different tasks, usually under
|
|
14
|
+
// different ideas, and confusing them dims the wrong star.
|
|
15
|
+
//
|
|
16
|
+
// THE PROSE IS NOT THE RECORD. `cascade.js:removalProposalBody` already renders
|
|
17
|
+
// the attribution for a person to read. removal-proposals.js refuses to parse it
|
|
18
|
+
// back — "a body is written for a person to read, and a reader that silently
|
|
19
|
+
// depends on its line shape breaks the day somebody improves the wording" — and
|
|
20
|
+
// that refusal is only safe because of this table. Both the prose and these rows
|
|
21
|
+
// are rendered from the SAME derivation output (`{ taskId, title, paths }` off
|
|
22
|
+
// removal-derivation.js), so there is one source and they cannot disagree.
|
|
23
|
+
//
|
|
24
|
+
// WRITTEN WITH THE QUESTION, READ AFTER THE ANSWER. The attribution is only
|
|
25
|
+
// available while the cascade runs: it comes from GitHub commit-history reads,
|
|
26
|
+
// capped at eight paths per ship, on the server's shared push credential. Storing
|
|
27
|
+
// it at proposal time and joining the verdict later is what keeps `confirm` a
|
|
28
|
+
// pure database write instead of a second trip to GitHub against a history that
|
|
29
|
+
// has since moved.
|
|
30
|
+
//
|
|
31
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
32
|
+
// `../../src/module-api`. Nothing here imports another module; the economy module
|
|
33
|
+
// reads it through the `lifecycle.removals` port, never by require (ADR 0083
|
|
34
|
+
// rule 1).
|
|
35
|
+
|
|
36
|
+
const api = require('../../src/module-api');
|
|
37
|
+
|
|
38
|
+
const { pool } = api;
|
|
39
|
+
|
|
40
|
+
// A positive integer id as it arrives off a row, a caller or a derivation —
|
|
41
|
+
// where it may be a bigint serialised to a string. Anything else is dropped
|
|
42
|
+
// rather than sent to the database, and never coerced with Number(), which
|
|
43
|
+
// loses bigint precision. The same guard idea-size.js uses, for the same reason.
|
|
44
|
+
function idOf(v) {
|
|
45
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Paths as the derivation hands them: an array of non-empty strings. Anything
|
|
49
|
+
// else becomes an empty array rather than a null, because the column is NOT NULL
|
|
50
|
+
// and because the paths are the receipt, not the load-bearing half — a malformed
|
|
51
|
+
// path list must not cost us the attribution it came with.
|
|
52
|
+
function pathsOf(v) {
|
|
53
|
+
if (!Array.isArray(v)) return [];
|
|
54
|
+
return v.filter((p) => typeof p === 'string' && p.length > 0).map(String);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// recordRemovalTargets — store the attribution for ONE proposal.
|
|
58
|
+
//
|
|
59
|
+
// proposalTaskId the task the removal cascade filed
|
|
60
|
+
// removals the derivation's output: [{ taskId, title, paths }]
|
|
61
|
+
//
|
|
62
|
+
// Returns the number of rows written (0 when there was nothing to attribute).
|
|
63
|
+
//
|
|
64
|
+
// IDEMPOTENT, because its caller is. The cascade re-runs on a re-ship and on a
|
|
65
|
+
// reconciler sweep, and `createTask`'s (source, source_ref) idempotency hands
|
|
66
|
+
// back the ORIGINAL proposal row rather than minting a second one — so this
|
|
67
|
+
// writer is called again with the same proposal id. ON CONFLICT DO NOTHING makes
|
|
68
|
+
// that a no-op instead of a duplicate-key throw that would surface as a cascade
|
|
69
|
+
// error on every re-ship.
|
|
70
|
+
//
|
|
71
|
+
// DO NOTHING, NOT DO UPDATE, and that is a decision rather than a shortcut. A
|
|
72
|
+
// second derivation of the same ship can legitimately find FEWER paths — the
|
|
73
|
+
// commit-history walk is capped and rate-limited, and an exhausted page yields
|
|
74
|
+
// no attribution at all. Overwriting would let a degraded second read erase a
|
|
75
|
+
// good first one. The first attribution is the one taken while the diff was
|
|
76
|
+
// freshest; it stands.
|
|
77
|
+
//
|
|
78
|
+
// A `title` in the derivation output is deliberately NOT stored: it is the
|
|
79
|
+
// task's own title, which lives on `tasks` and can be joined for. Copying it
|
|
80
|
+
// here would be a second copy that goes stale the first time a title is edited.
|
|
81
|
+
//
|
|
82
|
+
// NEVER THROWS INTO THE SHIP. The cascade runner catches everything and turns it
|
|
83
|
+
// into a report, and this is called from inside that guard — but the ship has
|
|
84
|
+
// already landed, been graded and been paid before any of this runs, so an
|
|
85
|
+
// attribution that cannot be stored must cost the ship nothing. The caller
|
|
86
|
+
// reports; it does not fail.
|
|
87
|
+
async function recordRemovalTargets({ proposalTaskId, removals } = {}, { client = null } = {}) {
|
|
88
|
+
const proposal = idOf(proposalTaskId);
|
|
89
|
+
if (!proposal) return 0;
|
|
90
|
+
|
|
91
|
+
// De-duplicated on the way in. The derivation groups paths by task, so a
|
|
92
|
+
// repeat is not expected — but the primary key would reject the whole
|
|
93
|
+
// statement rather than the offending row, and a doubled pair inside ONE
|
|
94
|
+
// insert is not something ON CONFLICT rescues.
|
|
95
|
+
const byTask = new Map();
|
|
96
|
+
for (const r of Array.isArray(removals) ? removals : []) {
|
|
97
|
+
const taskId = idOf(r && r.taskId);
|
|
98
|
+
if (!taskId) continue;
|
|
99
|
+
if (byTask.has(taskId)) {
|
|
100
|
+
// Merge rather than drop: two entries for one task means the derivation
|
|
101
|
+
// split its paths, and losing half of them loses half the receipt.
|
|
102
|
+
const merged = new Set([...byTask.get(taskId), ...pathsOf(r.paths)]);
|
|
103
|
+
byTask.set(taskId, [...merged]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
byTask.set(taskId, pathsOf(r && r.paths));
|
|
107
|
+
}
|
|
108
|
+
if (byTask.size === 0) return 0;
|
|
109
|
+
|
|
110
|
+
const q = client || pool;
|
|
111
|
+
const payload = [...byTask.entries()].map(([taskId, paths]) => ({ taskId, paths }));
|
|
112
|
+
|
|
113
|
+
// ONE jsonb parameter, not two parallel arrays — and the reason is a trap
|
|
114
|
+
// worth naming. The obvious shape is `unnest($2::bigint[])` joined by
|
|
115
|
+
// ORDINALITY to `unnest($3::text[][])`, and it is wrong: a Postgres
|
|
116
|
+
// multidimensional array is rectangular and `unnest` FLATTENS it completely,
|
|
117
|
+
// so a task with two paths and a task with one do not even produce the same
|
|
118
|
+
// number of rows as their ids, and the ordinality join silently pairs the
|
|
119
|
+
// wrong path with the wrong task. jsonb keeps each task's paths nested where
|
|
120
|
+
// they belong, and it costs the same single round trip.
|
|
121
|
+
const { rowCount } = await q.query(
|
|
122
|
+
`INSERT INTO lifecycle_removal_targets (proposal_task_id, removed_task_id, paths)
|
|
123
|
+
SELECT $1::bigint,
|
|
124
|
+
(e->>'taskId')::bigint,
|
|
125
|
+
COALESCE(ARRAY(SELECT jsonb_array_elements_text(e->'paths')), '{}')
|
|
126
|
+
FROM jsonb_array_elements($2::jsonb) AS e
|
|
127
|
+
ON CONFLICT (proposal_task_id, removed_task_id) DO NOTHING`,
|
|
128
|
+
[proposal, JSON.stringify(payload)]
|
|
129
|
+
);
|
|
130
|
+
return rowCount || 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// confirmedRemovalsForTasks — of these tasks, which are named by a CONFIRMED
|
|
134
|
+
// removal, and by which proposal.
|
|
135
|
+
//
|
|
136
|
+
// This is the read brightness is built on. Returns
|
|
137
|
+
// Map<removedTaskIdString, { proposalTaskId, shipTaskId, paths, decidedAt }>;
|
|
138
|
+
// a task absent from the map is alive. Only 'confirmed' rows join — a dismissed
|
|
139
|
+
// proposal is a refactor or a move and must dim nothing, which is the entire
|
|
140
|
+
// reason the verdict is a person's answer rather than a derived signal
|
|
141
|
+
// (ADR 0313).
|
|
142
|
+
//
|
|
143
|
+
// ONE QUERY FOR THE WHOLE SET, not one per task: this runs behind a page of
|
|
144
|
+
// ideas, each with a descent, and a per-task lookup would be a round trip per
|
|
145
|
+
// node of every descent on screen.
|
|
146
|
+
//
|
|
147
|
+
// A task named by SEVERAL confirmed proposals collapses to the EARLIEST verdict.
|
|
148
|
+
// Work can be deleted, re-added and deleted again, and the question brightness
|
|
149
|
+
// asks is binary — is this alive — so the count of times it died is not the
|
|
150
|
+
// fact. The earliest is kept because it is the one whose paths describe the
|
|
151
|
+
// original removal rather than a later re-removal of something restored in
|
|
152
|
+
// between, and because a stable pick makes the read deterministic.
|
|
153
|
+
async function confirmedRemovalsForTasks(taskIds, { client = null } = {}) {
|
|
154
|
+
const ids = [...new Set((Array.isArray(taskIds) ? taskIds : []).map(idOf).filter(Boolean))];
|
|
155
|
+
const out = new Map();
|
|
156
|
+
if (ids.length === 0) return out;
|
|
157
|
+
|
|
158
|
+
const q = client || pool;
|
|
159
|
+
const { rows } = await q.query(
|
|
160
|
+
`SELECT tg.removed_task_id,
|
|
161
|
+
tg.proposal_task_id,
|
|
162
|
+
tg.paths,
|
|
163
|
+
v.ship_task_id,
|
|
164
|
+
v.decided_at
|
|
165
|
+
FROM lifecycle_removal_targets tg
|
|
166
|
+
JOIN lifecycle_removal_verdicts v ON v.proposal_task_id = tg.proposal_task_id
|
|
167
|
+
WHERE v.verdict = 'confirmed'
|
|
168
|
+
AND tg.removed_task_id = ANY($1::bigint[])
|
|
169
|
+
ORDER BY tg.removed_task_id, v.decided_at ASC, tg.proposal_task_id ASC`,
|
|
170
|
+
[ids]
|
|
171
|
+
);
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
const key = idOf(row.removed_task_id);
|
|
174
|
+
if (!key || out.has(key)) continue; // ORDER BY put the earliest first
|
|
175
|
+
out.set(key, {
|
|
176
|
+
proposalTaskId: String(row.proposal_task_id),
|
|
177
|
+
shipTaskId: row.ship_task_id == null ? null : String(row.ship_task_id),
|
|
178
|
+
paths: Array.isArray(row.paths) ? row.paths : [],
|
|
179
|
+
decidedAt: row.decided_at || null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// countUnattributedConfirmed — how many CONFIRMED removals name no target at all.
|
|
186
|
+
//
|
|
187
|
+
// The number nothing else can tell you, and the reason it is exported rather
|
|
188
|
+
// than left implicit. A confirmed removal with no target rows is invisible to
|
|
189
|
+
// brightness by construction: the work is genuinely gone, and no star dims for
|
|
190
|
+
// it. That happens for proposals filed before lifecycle_010, for attributions
|
|
191
|
+
// where every deletion predates `claims.published_head_sha`, and for a
|
|
192
|
+
// derivation outage.
|
|
193
|
+
//
|
|
194
|
+
// Brightness cannot fix that — it does not know which idea to dim, and guessing
|
|
195
|
+
// is worse than not dimming. What it can do is refuse to let the gap be silent.
|
|
196
|
+
// A surface over the sky (BV2.R17/R18) can say "the sky may read brighter than
|
|
197
|
+
// the truth by up to N confirmed removals", which is an honest caveat, where a
|
|
198
|
+
// silently-optimistic picture is the quiet lie the spec names.
|
|
199
|
+
async function countUnattributedConfirmed({ client = null } = {}) {
|
|
200
|
+
const q = client || pool;
|
|
201
|
+
const { rows } = await q.query(
|
|
202
|
+
`SELECT COUNT(*)::int AS n
|
|
203
|
+
FROM lifecycle_removal_verdicts v
|
|
204
|
+
WHERE v.verdict = 'confirmed'
|
|
205
|
+
AND NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM lifecycle_removal_targets tg
|
|
207
|
+
WHERE tg.proposal_task_id = v.proposal_task_id
|
|
208
|
+
)`
|
|
209
|
+
);
|
|
210
|
+
return (rows[0] && rows[0].n) || 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
recordRemovalTargets,
|
|
215
|
+
confirmedRemovalsForTasks,
|
|
216
|
+
countUnattributedConfirmed,
|
|
217
|
+
};
|
|
@@ -29,6 +29,7 @@ const lifecycle = require('../lifecycle');
|
|
|
29
29
|
const db = require('../db');
|
|
30
30
|
const cascade = require('../cascade');
|
|
31
31
|
const removalDerivation = require('../removal-derivation');
|
|
32
|
+
const removalTargets = require('../removal-targets');
|
|
32
33
|
|
|
33
34
|
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
34
35
|
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
@@ -102,6 +103,14 @@ function registerSeams() {
|
|
|
102
103
|
removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
|
|
103
104
|
shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
|
|
104
105
|
}),
|
|
106
|
+
// WHOSE WORK IT WAS, stored (task 1004099 / BV2.R16 brightness). The
|
|
107
|
+
// derivation above already computed the attribution and the proposal body
|
|
108
|
+
// renders it as prose; this persists the same objects so brightness can
|
|
109
|
+
// read them structured. Passed as the NAMED function rather than wrapped in
|
|
110
|
+
// a closure, for the reason the note above gives: a closure here is not
|
|
111
|
+
// reachable from a test, and that is how the `number`/`pr_number` bug lived
|
|
112
|
+
// in production with a green suite.
|
|
113
|
+
recordRemovalTargets: removalTargets.recordRemovalTargets,
|
|
105
114
|
log: api.logger ? api.logger('lifecycle') : null,
|
|
106
115
|
}));
|
|
107
116
|
}
|
|
@@ -32,9 +32,34 @@
|
|
|
32
32
|
const express = require('express');
|
|
33
33
|
const api = require('../../../src/module-api');
|
|
34
34
|
const proposals = require('../removal-proposals');
|
|
35
|
+
const removalTargets = require('../removal-targets');
|
|
35
36
|
|
|
36
37
|
const log = api.logger('lifecycle');
|
|
37
38
|
|
|
39
|
+
// THE `lifecycle.removals` PORT (task 1004099 / BV2.R16 brightness).
|
|
40
|
+
//
|
|
41
|
+
// Brightness is the live share of an idea's descent, and it lives in the ECONOMY
|
|
42
|
+
// module beside size — because it is a share of the same payouts, and putting it
|
|
43
|
+
// in lifecycle would mean economy asking lifecycle for money arithmetic. That
|
|
44
|
+
// makes "which of these tasks were removed" a cross-module read, and the one-way
|
|
45
|
+
// rule (ADR 0083 rule 1) says it crosses through a kernel-mediated port, never a
|
|
46
|
+
// require. This is the same shape as `ideas.descent`, which economy already
|
|
47
|
+
// resolves for the descent walk itself.
|
|
48
|
+
//
|
|
49
|
+
// REGISTERED HERE, in a route file, because that is where a module's ports are
|
|
50
|
+
// registered (the `ideas.capture` / `lifecycle.kickoffSeed` precedent) — the
|
|
51
|
+
// registration runs when the module mounts, so nothing resolves a port from a
|
|
52
|
+
// module that is switched off.
|
|
53
|
+
//
|
|
54
|
+
// RESOLVED OPTIONALLY on the far side. Economy degrades to "cannot attribute"
|
|
55
|
+
// when lifecycle is absent rather than failing the read, so this is not declared
|
|
56
|
+
// in economy's `consumes` — the same treatment `ideas.descent` gets, and the
|
|
57
|
+
// reason `consumes` there is still `[]`.
|
|
58
|
+
const removalsPort = Object.freeze({
|
|
59
|
+
confirmedRemovalsForTasks: removalTargets.confirmedRemovalsForTasks,
|
|
60
|
+
countUnattributedConfirmed: removalTargets.countUnattributedConfirmed,
|
|
61
|
+
});
|
|
62
|
+
|
|
38
63
|
// The floor: `task.promote` is Metic+ and is the atom the nod already carries,
|
|
39
64
|
// which is what makes 'the same floor as the nod' a fact about the gate rather
|
|
40
65
|
// than a claim in a comment.
|
|
@@ -48,6 +73,14 @@ const log = api.logger('lifecycle');
|
|
|
48
73
|
module.exports = function buildRemovalProposalsRouter() {
|
|
49
74
|
const router = express.Router();
|
|
50
75
|
|
|
76
|
+
// Guarded, because the router factory runs again every time a test rebuilds
|
|
77
|
+
// it and registerProvider THROWS on a duplicate — a port is single-provider by
|
|
78
|
+
// design. The `lifecycle` port's own registration in routes/lifecycle.js uses
|
|
79
|
+
// the identical guard for the identical reason.
|
|
80
|
+
if (!api.hasProvider('lifecycle.removals')) {
|
|
81
|
+
api.registerProvider('lifecycle.removals', removalsPort);
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
// GET /removal-proposals — the removal questions nobody has answered yet.
|
|
52
85
|
//
|
|
53
86
|
// rank: metic+archon (task.promote). No client-side rank gate is needed or wanted in the
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.922",
|
|
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.922",
|
|
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.922",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
33
|
-
const { standaloneRoot } = require('./provision-repo.js');
|
|
33
|
+
const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
34
34
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
35
35
|
|
|
36
36
|
// The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
|
|
@@ -186,7 +186,31 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
186
186
|
// the silent thirteen-release revert upgrade.js's own header records.
|
|
187
187
|
const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
|
|
188
188
|
const versionUrl = `http://127.0.0.1:${inst.port}/version`;
|
|
189
|
-
|
|
189
|
+
// WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
|
|
190
|
+
//
|
|
191
|
+
// A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
|
|
192
|
+
// CURRENT OS USER rather than a flag:
|
|
193
|
+
//
|
|
194
|
+
// • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
|
|
195
|
+
// is almost never a Postgres role — pre-flight refuses with "Postgres has no
|
|
196
|
+
// role root" before the pin moves (correctly; the pin must not move for a
|
|
197
|
+
// migrate that cannot run).
|
|
198
|
+
// • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
|
|
199
|
+
// node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
|
|
200
|
+
// repo that installs its workspaces rather than its root has no core there, so
|
|
201
|
+
// the name cannot be resolved at all.
|
|
202
|
+
//
|
|
203
|
+
// Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
|
|
204
|
+
// pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
|
|
205
|
+
// provision-repo.js — this leg simply never used them.
|
|
206
|
+
//
|
|
207
|
+
// The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
|
|
208
|
+
// Postgres login role (instanceDbRole), so peer auth lands on a role that owns
|
|
209
|
+
// exactly that instance's database. The control plane is the exception: it is not a
|
|
210
|
+
// provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
|
|
211
|
+
// the recipe's gate-3 invocation names.
|
|
212
|
+
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
213
|
+
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
190
214
|
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
|
|
191
215
|
//
|
|
192
216
|
// `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
|
|
@@ -231,9 +231,25 @@ const CLEAN_VERDICT_RE = /\b[1-9]\d* passed, 0 failed(?:, \d+ skipped)?\s*$/;
|
|
|
231
231
|
// line so a suite that prints skips without a summary suffix is still surfaced.
|
|
232
232
|
const SKIP_LINE_RE = /^\s*SKIP\b/;
|
|
233
233
|
|
|
234
|
+
// node:test suites print no SKIP line at all — they report `ℹ skipped N` in their own summary
|
|
235
|
+
// (task 1004112). Until this, every skip in a node:test suite was INVISIBLE to the roll-up whose
|
|
236
|
+
// entire purpose is to say "a skipped case is not a passing one": the suite printed `ok`, the
|
|
237
|
+
// roll-up counted nothing, and the unrun coverage vanished without a trace.
|
|
238
|
+
//
|
|
239
|
+
// Found while fixing this counter's own subject. Gating four unevaluable cases in
|
|
240
|
+
// tests/skip_is_not_pass.mjs turned four honest FAILURES into four invisible skips — the 1003993
|
|
241
|
+
// defect reappearing one layer up, in the file written to prevent it. A skip that nothing counts
|
|
242
|
+
// is a pass with extra steps.
|
|
243
|
+
const NODE_TEST_SKIP_RE = /^\s*(?:ℹ|#)\s*skipped\s+(\d+)\s*$/m;
|
|
244
|
+
|
|
234
245
|
function countSkips(stdout) {
|
|
235
246
|
if (!stdout) return 0;
|
|
236
|
-
|
|
247
|
+
const lines = stdout.split('\n').filter((l) => SKIP_LINE_RE.test(l)).length;
|
|
248
|
+
// The two formats are mutually exclusive in practice (a suite is either the project's own
|
|
249
|
+
// harness or node:test), but SUM rather than branch: a suite that grew both would otherwise
|
|
250
|
+
// report whichever we happened to check first, and under-reporting is the failure mode here.
|
|
251
|
+
const m = stdout.match(NODE_TEST_SKIP_RE);
|
|
252
|
+
return lines + (m ? Number(m[1]) : 0);
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
function isCleanTeardownAbort(stdout, stderr) {
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -74,6 +74,11 @@ async function shipMain() {
|
|
|
74
74
|
// task 1003950: set only when the empty-diff guard below classifies this ship as a
|
|
75
75
|
// deliberate no-artifact close. Gates the auto-escalation at the confirm wall.
|
|
76
76
|
let noArtifactClose = false;
|
|
77
|
+
// task 1004011: this ship has no committed diff, so the grade's subject is the
|
|
78
|
+
// builder's CLAIM (notes + value summary) rather than a diff. Distinct from
|
|
79
|
+
// noArtifactClose, which marks the older "reached the archon gate" case: a ship
|
|
80
|
+
// can now be no-artifact and still fully graded, which is the point.
|
|
81
|
+
let noArtifactGrade = false;
|
|
77
82
|
// task 1002605 (G-18): explicit opt-in to continue past a FAILED production
|
|
78
83
|
// smoke run. Without it a smoke FAIL now ABORTS before the claim resolves
|
|
79
84
|
// (claim intact); with it, the resolve proceeds and the task deliberately
|
|
@@ -267,16 +272,45 @@ async function shipMain() {
|
|
|
267
272
|
process.exit(1);
|
|
268
273
|
}
|
|
269
274
|
// verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
275
|
+
//
|
|
276
|
+
// task 1004011 (ADR 0320): this used to force `skipGrade = true` — no grade at
|
|
277
|
+
// all, then the Archon-only `task.confirm.grade_bypass` gate. Both halves were
|
|
278
|
+
// wrong. An empty diff is not an UNGRADED diff, it is a diff the grader has
|
|
279
|
+
// nothing to say about, so it should not need the key that ratifies past a
|
|
280
|
+
// FAILING grade. And something here does deserve adversarial scrutiny: not the
|
|
281
|
+
// absent diff, but the CLAIM that nothing was needed. "The premise was already
|
|
282
|
+
// fixed", "this was a pure ops verification" are assertions the ledger records
|
|
283
|
+
// and, until now, nothing checked.
|
|
284
|
+
//
|
|
285
|
+
// So the grader is no longer skipped — it is re-pointed at the notes. A pass is
|
|
286
|
+
// a REAL passing grade and confirms through the ordinary rank-open path; a fail
|
|
287
|
+
// is a real fail, and the builder fixes the evidence rather than reaching for a
|
|
288
|
+
// permission. skipGrade stays false; only an explicit --skip-grade still skips.
|
|
289
|
+
// Set only when a grade will actually run. With --skip-grade (or the server-side
|
|
290
|
+
// grader_bypass, which forces skipGrade above) the `if (!skipGrade)` gate below
|
|
291
|
+
// skips grading entirely and this flag would be inert — but an inert flag makes a
|
|
292
|
+
// reader trace two of them to see why nothing happened, and invites a later
|
|
293
|
+
// refactor to treat it as "this ship graded its notes" when it graded nothing.
|
|
294
|
+
noArtifactGrade = !skipGrade;
|
|
273
295
|
// task 1003950: mark WHY the grader is being skipped. A --skip-grade ship past a
|
|
274
296
|
// failing grade and a genuinely artifact-free close both reach the same archon
|
|
275
297
|
// gate below, but only the second one may be escalated automatically — the first
|
|
276
298
|
// has a diff the grader DID read and reject, and auto-filing a blocker for it
|
|
277
299
|
// would be a builder routing their own rejected work to an override queue.
|
|
300
|
+
//
|
|
301
|
+
// Still set, and still needed: since task 1004011 this close reaches the archon
|
|
302
|
+
// gate only when the builder ALSO passed --skip-grade, or the server bypassed
|
|
303
|
+
// the grader for them. Those are the cases where the escalation split still
|
|
304
|
+
// decides which refusal is printed.
|
|
278
305
|
noArtifactClose = true;
|
|
279
|
-
console.log('--allow-empty: no committed diff (no-artifact / DB-only ship)
|
|
306
|
+
console.log('--allow-empty: no committed diff (no-artifact / DB-only ship).');
|
|
307
|
+
if (noArtifactGrade) {
|
|
308
|
+
console.log(' The grader is not skipped — it is re-pointed: your handoff notes and value summary are graded');
|
|
309
|
+
console.log(' against the task description, so the claim that nothing was needed is what gets checked (task 1004011).');
|
|
310
|
+
} else {
|
|
311
|
+
console.log(' The grader is skipped (--skip-grade / server bypass), so the claim is NOT checked — this close');
|
|
312
|
+
console.log(' routes to the archon confirm gate. Drop --skip-grade to have your notes graded instead.');
|
|
313
|
+
}
|
|
280
314
|
}
|
|
281
315
|
|
|
282
316
|
// ---- Module drift check (BV1.R53, task 1422) ----
|
|
@@ -411,10 +445,15 @@ async function shipMain() {
|
|
|
411
445
|
if (!skipGrade) {
|
|
412
446
|
const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
|
|
413
447
|
fullTaskForGrade = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
|
|
414
|
-
console.log(
|
|
448
|
+
console.log(noArtifactGrade
|
|
449
|
+
? 'Starting the no-artifact grader over your notes (overlaps the smoke run — task 1002660)...'
|
|
450
|
+
: 'Starting the quality grader panel (overlaps the smoke run — task 1002660)...');
|
|
415
451
|
gradeCompute = computeGradeResult({
|
|
416
452
|
taskId, task: fullTaskForGrade, summary,
|
|
417
453
|
baseline: graderBaseline, committedFiles,
|
|
454
|
+
// task 1004011: present ⇒ grade the claim, not the (empty) diff. The notes
|
|
455
|
+
// are the evidence, so they have to travel with it.
|
|
456
|
+
noArtifact: noArtifactGrade ? { notes } : null,
|
|
418
457
|
});
|
|
419
458
|
gradeCompute.catch(() => {}); // an abort path must not die on an unhandled rejection
|
|
420
459
|
}
|
|
@@ -613,12 +652,16 @@ async function shipMain() {
|
|
|
613
652
|
gradePassed = true; // by builder fiat, not by grader
|
|
614
653
|
} else if (passed && taskStatus === 'completed') {
|
|
615
654
|
console.log('');
|
|
616
|
-
console.log(
|
|
655
|
+
console.log(noArtifactGrade
|
|
656
|
+
? 'Running the no-artifact grader (subject: your claim that nothing was needed)...'
|
|
657
|
+
: 'Running subagent quality grader...');
|
|
617
658
|
|
|
618
659
|
// #513 + #536: the grader reads COMMITTED history (baseline...HEAD), not
|
|
619
660
|
// the working tree. The empty-committed-diff guard now runs earlier — BEFORE
|
|
620
661
|
// the claim is resolved (see above) — so by the time we reach here the diff
|
|
621
|
-
// is guaranteed non-empty on the grader path
|
|
662
|
+
// is guaranteed non-empty on the grader path, EXCEPT on the task 1004011
|
|
663
|
+
// no-artifact species, where it is guaranteed empty and the notes are the
|
|
664
|
+
// subject instead. Reuse the baseline + file list
|
|
622
665
|
// computed there so we don't diff twice. #678: gradeAndRecord POSTs the
|
|
623
666
|
// result and logs the panel cost — the same unit the --regrade path reuses.
|
|
624
667
|
// task 1002660: the panel computation was started BEFORE the smoke gate
|
|
@@ -631,6 +674,10 @@ async function shipMain() {
|
|
|
631
674
|
baseline: graderBaseline,
|
|
632
675
|
committedFiles,
|
|
633
676
|
precomputed: gradeCompute,
|
|
677
|
+
// No `noArtifact` here on purpose: `precomputed` is ALWAYS set on this path, and
|
|
678
|
+
// gradeAndRecord only falls back to computing inline when it is absent. Passing it would be
|
|
679
|
+
// unreachable by construction — defensive symmetry that reads like a live argument. The
|
|
680
|
+
// species reaches the grader through gradeCompute above, which was built with it.
|
|
634
681
|
});
|
|
635
682
|
|
|
636
683
|
if (!gradePost.ok) {
|
|
@@ -19,7 +19,7 @@ const { plainCardLines } = require('./plain-cards.js');
|
|
|
19
19
|
// so the grader outage explains itself with the same `claude CLI` verdict doctor
|
|
20
20
|
// gives, and the two can never drift.
|
|
21
21
|
const { preflightChecks } = require('./preflight.js');
|
|
22
|
-
const { computeRawDiffHash, reconstructPanelIssues, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
|
|
22
|
+
const { computeRawDiffHash, reconstructPanelIssues, runNoArtifactGrader, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
|
|
23
23
|
|
|
24
24
|
// ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
|
|
25
25
|
//
|
|
@@ -168,7 +168,41 @@ function buildPriorRoundContext({ storedGrade, responseText = '', interimCommits
|
|
|
168
168
|
// writes, so shipMain can start it before the smoke gate and overlap the
|
|
169
169
|
// multi-minute panel with smoke + the claim resolve. Returns
|
|
170
170
|
// { kind: 'reuse', currentHash, reusedResult } | { kind: 'fresh', currentHash, gradeResult }.
|
|
171
|
-
|
|
171
|
+
// `runNoArtifact` / `runDiffGrader` are injection seams, defaulted to the real
|
|
172
|
+
// runners. The routing decision and the diff-hash policy below are the parts worth
|
|
173
|
+
// testing, and BOTH real runners spawn a model — a test that had to spawn one to
|
|
174
|
+
// reach them would be slow, billable and non-deterministic, so it would not be
|
|
175
|
+
// written, so they would not be covered. Both seams exist because the negative case
|
|
176
|
+
// matters as much as the positive one: "an ordinary ship must NOT reach the
|
|
177
|
+
// no-artifact grader" is only assertable if the ordinary path can be reached
|
|
178
|
+
// without paying for a panel. Production passes neither.
|
|
179
|
+
async function computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound = null, noArtifact = null, runNoArtifact = runNoArtifactGrader, runDiffGrader = runSubagentGrader }) {
|
|
180
|
+
// task 1004011: the no-artifact species. There is no diff, so every step below
|
|
181
|
+
// this is inapplicable rather than merely empty — the hash is of nothing, the
|
|
182
|
+
// reuse lookup would match any other empty-diff grade on this task, and the
|
|
183
|
+
// panel has no subject. Return early with the claim graded instead.
|
|
184
|
+
//
|
|
185
|
+
// `noArtifact` carries the notes, because they are the EVIDENCE here and this
|
|
186
|
+
// function is otherwise handed only the diff-derived inputs.
|
|
187
|
+
//
|
|
188
|
+
// `priorRound` is DELIBERATELY not threaded through. It is the delta-aware re-grade
|
|
189
|
+
// context (task 1002662): the prior round's findings, the builder's response, and
|
|
190
|
+
// the INTERIM DIFF between the previously graded HEAD and now. On a no-artifact
|
|
191
|
+
// ship there is no interim diff by construction, and --regrade refuses an empty
|
|
192
|
+
// diff anyway (task 1003950), so there is no path that reaches here with a
|
|
193
|
+
// meaningful prior round. Passing it would look like remediation context while
|
|
194
|
+
// carrying nothing that applies. Named here so the omission reads as a decision.
|
|
195
|
+
if (noArtifact) {
|
|
196
|
+
const gradeResult = await runNoArtifact({
|
|
197
|
+
task, valueSummary: summary, notes: noArtifact.notes,
|
|
198
|
+
});
|
|
199
|
+
// currentHash stays null on purpose: diff_hash is the reuse key for a grade
|
|
200
|
+
// over a diff, and storing a hash of the empty diff would let the NEXT
|
|
201
|
+
// empty-diff ship of this task reuse THIS grade — a different claim,
|
|
202
|
+
// different notes, silently inheriting a pass it never earned.
|
|
203
|
+
return { kind: 'fresh', currentHash: null, gradeResult };
|
|
204
|
+
}
|
|
205
|
+
|
|
172
206
|
// Compute the fingerprint of the raw (untruncated) diff so we can compare
|
|
173
207
|
// against any stored grade, independent of the display-truncation cap.
|
|
174
208
|
const currentHash = computeRawDiffHash(baseline);
|
|
@@ -205,7 +239,7 @@ async function computeGradeResult({ taskId, task, summary, baseline, committedFi
|
|
|
205
239
|
}
|
|
206
240
|
}
|
|
207
241
|
|
|
208
|
-
const gradeResult = await
|
|
242
|
+
const gradeResult = await runDiffGrader({
|
|
209
243
|
task,
|
|
210
244
|
valueSummary: summary,
|
|
211
245
|
baseline,
|
|
@@ -225,9 +259,13 @@ async function computeGradeResult({ taskId, task, summary, baseline, committedFi
|
|
|
225
259
|
// (a promise from computeGradeResult, task 1002660) lets the ship path hand in
|
|
226
260
|
// the overlapped computation; the --regrade path passes nothing and computes
|
|
227
261
|
// inline — behavior identical to the pre-split gradeAndRecord either way.
|
|
228
|
-
async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null, precomputed = null }) {
|
|
262
|
+
async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null, precomputed = null, noArtifact = null }) {
|
|
263
|
+
// task 1004011: forwarded so the two entry points cannot diverge. The ship path
|
|
264
|
+
// hands in `precomputed` (the overlapped promise) and never reaches the inline
|
|
265
|
+
// call; anything computing inline must be able to reach the no-artifact species
|
|
266
|
+
// too, or the next caller silently grades an empty diff with the panel.
|
|
229
267
|
const computed = await (precomputed
|
|
230
|
-
|| computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound }));
|
|
268
|
+
|| computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound, noArtifact }));
|
|
231
269
|
|
|
232
270
|
if (computed.kind === 'reuse') {
|
|
233
271
|
const payload = { ...buildGradePayload(computed.reusedResult, committedFiles), diff_hash: computed.currentHash };
|
|
@@ -344,6 +344,41 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
|
|
|
344
344
|
return result;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
// task 1004011: the no-artifact twin of runSubagentGrader. Same station in the
|
|
348
|
+
// ship, different subject — there is no diff, so the thing graded is the builder's
|
|
349
|
+
// CLAIM that none was needed (modules/grading/grader-no-artifact.js, ADR 0320).
|
|
350
|
+
//
|
|
351
|
+
// WHY a separate runner rather than a flag on the one above: every input that one
|
|
352
|
+
// assembles is diff-derived (changed files, the unified diff, the shortstat, the
|
|
353
|
+
// generated-provenance check, the deterministic route-rank and permission-path
|
|
354
|
+
// pre-passes over the changed files). On an empty diff each of those is an empty
|
|
355
|
+
// set, and an empty set fed to a check that reports "no hits" is a check that
|
|
356
|
+
// passes vacuously — the exact failure mode task 1002646's Guard 1b exists to
|
|
357
|
+
// catch server-side. Threading `if (empty)` through all of them would be a wider
|
|
358
|
+
// change with more places to get that wrong than a sibling that never computes
|
|
359
|
+
// them at all.
|
|
360
|
+
async function runNoArtifactGrader({ task, valueSummary, notes, runOpts: extraOpts = {} }) {
|
|
361
|
+
const builderModel = resolveBuilderModel();
|
|
362
|
+
const graderModel = grader.selectGraderModel({ builderModel });
|
|
363
|
+
const runOpts = {
|
|
364
|
+
timeoutMs: GRADER_TIMEOUT_MS,
|
|
365
|
+
cwd: process.cwd(), // grade from the FEATURE worktree — the Read tool is the only evidence check here
|
|
366
|
+
...extraOpts,
|
|
367
|
+
};
|
|
368
|
+
console.log(` subagent grader ${grader.VERSION}: NO-ARTIFACT grade (empty committed diff) — one generalist shot over the handoff notes + value summary, model=${graderModel}`);
|
|
369
|
+
console.log(` subject: the CLAIM that nothing was needed, judged against the task description — not a diff, and not the absence of one`);
|
|
370
|
+
console.log(` gate: unchanged — functional_fidelity>=${grader.THRESHOLD} AND code_quality>=${grader.THRESHOLD} AND confidence>=0.4, re-asked for this species (is it genuinely artifact-free / is the evidence checkable)`);
|
|
371
|
+
const t0 = Date.now();
|
|
372
|
+
const result = await grader.gradeNoArtifact({ task, valueSummary, notes, builderModel, runOpts });
|
|
373
|
+
result.signals = {
|
|
374
|
+
...(result.signals || {}),
|
|
375
|
+
wall_ms: Date.now() - t0,
|
|
376
|
+
changed_files_count: 0,
|
|
377
|
+
graded_head_sha: gitOk('git rev-parse HEAD') || null,
|
|
378
|
+
};
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
347
382
|
function runSmoke() {
|
|
348
383
|
// `cwd: REPO_ROOT` + a relative script path is robust to spaces or other
|
|
349
384
|
// shell metacharacters that may appear in the absolute repo path on some
|
|
@@ -376,6 +411,7 @@ module.exports = {
|
|
|
376
411
|
diffHash,
|
|
377
412
|
reconstructPanelIssues,
|
|
378
413
|
resolveBaseRef,
|
|
414
|
+
runNoArtifactGrader,
|
|
379
415
|
runSmoke,
|
|
380
416
|
runSmokeAsync,
|
|
381
417
|
runSubagentGrader,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.922'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|