@bongos/core 1.19.383 → 1.19.385
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 +29 -19
- package/docs/adr/0195-adr-numbers-are-checked-like-migration-numbers.md +46 -0
- package/docs/adr/README.md +34 -23
- package/modules/grading/grader-workers/index.js +46 -3
- package/modules/grading/grader.js +25 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/adr-namespace.js +273 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/ship-grade-steps.js +43 -2
- package/src/module-api.js +5 -1
- package/tests/fitness.mjs +118 -0
- package/tests/grade_unavailable_display.mjs +48 -0
- package/tests/grader_panel.mjs +84 -0
|
@@ -70,6 +70,35 @@ const WORKER_MAX_TIMEOUT_MS = {
|
|
|
70
70
|
efficiency: 360_000, // Read-heavy on a large diff: traces perf across every changed file
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
// task 1003313: the diagnostic a dark panel used to throw away.
|
|
74
|
+
//
|
|
75
|
+
// runSubagent attaches exit_code + stdout + stderr to every subprocess error,
|
|
76
|
+
// but the catch below kept only err.code — so a TOTAL outage stored four
|
|
77
|
+
// identical `SUBAGENT_NONZERO_EXIT` rows and nothing else, and two builders sat
|
|
78
|
+
// dark for days with a 0/10 card that could not name its own cause. At least two
|
|
79
|
+
// known environmental faults produce that exact signature (a logged-out `claude`
|
|
80
|
+
// CLI — preflight.js claudeAuthFinding; the Windows WindowsApps alias stub —
|
|
81
|
+
// learning 42) and the stored grade could not tell them apart.
|
|
82
|
+
//
|
|
83
|
+
// The tail is REDACTED and BOUNDED because it lands in a grade row that the hall
|
|
84
|
+
// renders: CLI stderr can carry an auth header or a key echoed back in an error.
|
|
85
|
+
const STDERR_TAIL_MAX = 400;
|
|
86
|
+
// eslint-disable-next-line no-control-regex -- stripping ANSI SGR from CLI stderr
|
|
87
|
+
const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]/g;
|
|
88
|
+
const SECRET_RES = [
|
|
89
|
+
/sk-ant-[A-Za-z0-9_-]+/g,
|
|
90
|
+
/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
|
|
91
|
+
/github_pat_[A-Za-z0-9_]{20,}/g,
|
|
92
|
+
/\b[Bb]earer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
|
|
93
|
+
];
|
|
94
|
+
function redactStderrTail(raw) {
|
|
95
|
+
let s = String(raw || '').replace(ANSI_RE, '').trim();
|
|
96
|
+
if (!s) return null;
|
|
97
|
+
for (const re of SECRET_RES) s = s.replace(re, '[redacted]');
|
|
98
|
+
s = s.slice(-STDERR_TAIL_MAX);
|
|
99
|
+
return s || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
// Resolve the timeout for a worker: explicit module override > KIND map >
|
|
74
103
|
// caller's runOpts > undefined (runSubagent applies its own default).
|
|
75
104
|
function timeoutForWorker(worker, runOpts) {
|
|
@@ -127,6 +156,9 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
127
156
|
let runMs = null;
|
|
128
157
|
let costUsd = null;
|
|
129
158
|
let subprocessError = null;
|
|
159
|
+
// task 1003313: the WHY behind subprocessError. Null on a clean run.
|
|
160
|
+
let exitCode = null;
|
|
161
|
+
let stderrTail = null;
|
|
130
162
|
const t0 = Date.now();
|
|
131
163
|
try {
|
|
132
164
|
const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt, opts: { ...runOpts, model, timeoutMs } });
|
|
@@ -139,6 +171,11 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
139
171
|
} catch (err) {
|
|
140
172
|
runMs = err.run_ms ?? (Date.now() - t0);
|
|
141
173
|
subprocessError = err.code || 'UNKNOWN';
|
|
174
|
+
exitCode = Number.isInteger(err.exit_code) ? err.exit_code : null;
|
|
175
|
+
// A CLI that dies before writing JSON often says why on stdout, not stderr
|
|
176
|
+
// (`claude` prints its "not logged in" line there) — fall back so the tail
|
|
177
|
+
// is never empty when the subprocess did explain itself.
|
|
178
|
+
stderrTail = redactStderrTail(err.stderr) ?? redactStderrTail(err.stdout);
|
|
142
179
|
}
|
|
143
180
|
const parsed = subprocessError
|
|
144
181
|
? null
|
|
@@ -148,10 +185,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
148
185
|
run_ms: runMs,
|
|
149
186
|
subprocess_error: subprocessError,
|
|
150
187
|
});
|
|
151
|
-
return { verdict, runMs, costUsd };
|
|
188
|
+
return { verdict, runMs, costUsd, exitCode, stderrTail };
|
|
152
189
|
}
|
|
153
190
|
|
|
154
|
-
let { verdict, runMs, costUsd } = await attempt();
|
|
191
|
+
let { verdict, runMs, costUsd, exitCode, stderrTail } = await attempt();
|
|
155
192
|
// #465: retry ONCE on a transient error (the freshest attempt wins, even if it
|
|
156
193
|
// also errored — one retry only). A real verdict or a SUBAGENT_TIMEOUT is
|
|
157
194
|
// never retried. NB (#486): the freshest attempt's cost wins; a transient
|
|
@@ -159,7 +196,7 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
159
196
|
let retried = false;
|
|
160
197
|
if (TRANSIENT_WORKER_ERRORS.has(verdict.error)) {
|
|
161
198
|
retried = true;
|
|
162
|
-
({ verdict, runMs, costUsd } = await attempt());
|
|
199
|
+
({ verdict, runMs, costUsd, exitCode, stderrTail } = await attempt());
|
|
163
200
|
}
|
|
164
201
|
|
|
165
202
|
return {
|
|
@@ -176,6 +213,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
176
213
|
cost_usd: costUsd, // #486: this worker's own subprocess API spend (USD)
|
|
177
214
|
retried,
|
|
178
215
|
error: verdict.error ?? null,
|
|
216
|
+
// task 1003313: on an errored worker, WHY. Omitted entirely on a clean run so
|
|
217
|
+
// a healthy grade row keeps its existing shape.
|
|
218
|
+
...(verdict.error && exitCode != null ? { exit_code: exitCode } : {}),
|
|
219
|
+
...(verdict.error && stderrTail ? { stderr_tail: stderrTail } : {}),
|
|
179
220
|
// Narc-specific (carried opaquely; aggregatePanel doesn't need to know):
|
|
180
221
|
...(verdict.ac_checklist ? { ac_checklist: verdict.ac_checklist } : {}),
|
|
181
222
|
};
|
|
@@ -188,6 +229,8 @@ module.exports = {
|
|
|
188
229
|
runWorker,
|
|
189
230
|
timeoutForWorker, // public surface; WORKER_MAX_TIMEOUT_MS stays module-internal
|
|
190
231
|
TRANSIENT_WORKER_ERRORS,
|
|
232
|
+
redactStderrTail, // task 1003313: exported so the redaction contract is pinned
|
|
233
|
+
STDERR_TAIL_MAX,
|
|
191
234
|
// re-export per-worker modules for direct access by tests
|
|
192
235
|
narc,
|
|
193
236
|
quality,
|
|
@@ -88,6 +88,27 @@ const { buildPrompt, extractJson } = require('./grader-prompt.js');
|
|
|
88
88
|
const { aggregateShots, changeMagnitude, hasPanelEvidence, isSubstantiveIssue, serverSideGuards, validateAndScore } = require('./grader-score.js');
|
|
89
89
|
const { buildContextBundle, diffAddedLinesByFile, extractAssistantResult, extractCostUsd, runOneShot, runSubagent, runSubagentCached } = require('./grader-subagent.js');
|
|
90
90
|
|
|
91
|
+
// task 1003313: render the WHY of a total outage into the verdict line.
|
|
92
|
+
//
|
|
93
|
+
// Exists because "GRADER UNAVAILABLE — all 4 workers failed to run" is not a
|
|
94
|
+
// diagnosis: a logged-out `claude` CLI, a Windows app-execution-alias stub, and a
|
|
95
|
+
// genuine API outage all produce it, and the builder sees no way to tell which.
|
|
96
|
+
// runWorker now carries exit_code + a redacted stderr_tail off each dead worker;
|
|
97
|
+
// this puts the shared exit code and the first distinct CLI error line into the
|
|
98
|
+
// text the builder actually reads. Silent when the workers carry no detail
|
|
99
|
+
// (a grade from a server predating the propagation).
|
|
100
|
+
const OUTAGE_TAIL_MAX = 300;
|
|
101
|
+
function describeOutage(gating) {
|
|
102
|
+
const dead = gating.filter((w) => w.error);
|
|
103
|
+
const codes = [...new Set(dead.map((w) => w.exit_code).filter((c) => c != null))];
|
|
104
|
+
const tails = [...new Set(dead.map((w) => w.stderr_tail).filter(Boolean))];
|
|
105
|
+
if (!codes.length && !tails.length) return '';
|
|
106
|
+
const parts = [];
|
|
107
|
+
if (codes.length) parts.push(`exit ${codes.join('/')}`);
|
|
108
|
+
if (tails.length) parts.push(`subprocess said: ${tails[0].slice(-OUTAGE_TAIL_MAX)}`);
|
|
109
|
+
return ` Diagnostic — ${parts.join('; ')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
function aggregatePanel(workerResults, meta = {}) {
|
|
92
113
|
const N = workerResults.length;
|
|
93
114
|
|
|
@@ -232,6 +253,10 @@ function aggregatePanel(workerResults, meta = {}) {
|
|
|
232
253
|
const erroredKinds = gating.filter((w) => w.error).map((w) => w.kind);
|
|
233
254
|
verdict =
|
|
234
255
|
`GRADER UNAVAILABLE — all ${gatingError}/${gating.length} gating worker(s) failed to run (${erroredKinds.join(', ')}); no quality signal was produced. This is an infra/outage condition, NOT a quality rejection — manual-confirm available.` +
|
|
256
|
+
// task 1003313: name the cause. Without this the card said only "failed to
|
|
257
|
+
// run" four times over, and two builders sat dark for days because nothing
|
|
258
|
+
// stored — or showed — the exit code and the CLI's own error line.
|
|
259
|
+
describeOutage(gating) +
|
|
235
260
|
advisoryNote;
|
|
236
261
|
panelOutcome = 'unavailable';
|
|
237
262
|
forwardedConfidence = FALLTHROUGH_CONFIDENCE;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.385",
|
|
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.385",
|
|
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.385",
|
|
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",
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// adr-namespace.js — ADR numbering discipline (task 1003164). The ADR half of the guard
|
|
4
|
+
// `migration-namespace.js` already gives migrations. Pure helpers over file lists + index
|
|
5
|
+
// text, plus the fitness check that reads the tree.
|
|
6
|
+
//
|
|
7
|
+
// PROBLEM. An ADR number is a CITATION TARGET. ADRs cite each other by number, prose and
|
|
8
|
+
// task notes cite them by number, and `docs/adr/README.md` is the index every agent is
|
|
9
|
+
// told to consult. Two files can carry the same number and NOTHING notices: the filenames
|
|
10
|
+
// differ (`0181-ship-requires-an-assurance.md` vs
|
|
11
|
+
// `0181-teardown-state-gate-and-address-retention.md`), so there is no path collision, no
|
|
12
|
+
// merge conflict, and git adds both cleanly. README.md auto-merges too, ending up with two
|
|
13
|
+
// `| 0181 |` rows. Unit, fitness, docs-entropy and the grader all passed; it was found on
|
|
14
|
+
// 2026-08-22 only because a human read `ls docs/adr/`. By then task 1000900's
|
|
15
|
+
// close-rationale had already cited "ADR 0180", which the collision had made point at an
|
|
16
|
+
// unrelated document — a citation that is wrong forever.
|
|
17
|
+
//
|
|
18
|
+
// THE ASYMMETRY THIS CLOSES. Migrations have exactly this protection already: a re-used
|
|
19
|
+
// migration number silently SKIPS a migration, so `stemCollisions()` hard-fails CI. ADRs
|
|
20
|
+
// got `next-number.js` — which computes the next free number at claim time, on a
|
|
21
|
+
// possibly-stale tree, best-effort — and then nothing enforced it afterwards. Same shared,
|
|
22
|
+
// monotonic, human-allocated number line; one guarded, one not. A stale claim card, two
|
|
23
|
+
// builders claiming the same day, or a branch that sits long enough all reproduce it.
|
|
24
|
+
//
|
|
25
|
+
// WHY GRANDFATHER RATHER THAN RENUMBER. When this landed the live tree already held TWENTY
|
|
26
|
+
// colliding numbers, not the one the bug report found. Renumbering nineteen pairs would
|
|
27
|
+
// break every existing citation to them — which is the very harm this check exists to
|
|
28
|
+
// prevent — and README.md states the rule outright: a number, once assigned, is never
|
|
29
|
+
// reused. So the existing pairs are frozen into LEGACY_DUPLICATE_ADRS by their EXACT
|
|
30
|
+
// filenames (the shape migration-namespace.js uses for its own grandfathering, and for
|
|
31
|
+
// RENAMED_MODULE_PREFIXES). Frozen by exact filename, not by number, is the load-bearing
|
|
32
|
+
// part: a THIRD file joining a legacy number still fails, and so does a rename of either
|
|
33
|
+
// half. The list can only shrink. See ADR 0195.
|
|
34
|
+
//
|
|
35
|
+
// The index half is checked by LINK TARGET, not by number, because a number rule alone
|
|
36
|
+
// cannot express the grandfathered pairs: the invariant is that every ADR file has exactly
|
|
37
|
+
// one row and every row points at a file that exists. That catches the dangling row AND
|
|
38
|
+
// the duplicated row AND the file nobody indexed — the last of which is how a collision
|
|
39
|
+
// actually hides, since the second file of a colliding pair is the one that never gets a
|
|
40
|
+
// row.
|
|
41
|
+
|
|
42
|
+
const fs = require('node:fs');
|
|
43
|
+
const path = require('node:path');
|
|
44
|
+
const { CORE_ROOT } = require('./fitness-lib.js');
|
|
45
|
+
|
|
46
|
+
// ---- parsing ---------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
// `NNNN-kebab-title.md` — the naming rule in docs/adr/README.md §File naming.
|
|
49
|
+
const ADR_RE = /^(\d{4})-(.+)\.md$/;
|
|
50
|
+
|
|
51
|
+
// Parse an ADR basename into { number, num, slug, ok }. `number` is the 4-digit STRING
|
|
52
|
+
// (the citation form, zero-padded); `num` is the integer for ordering/allocation. A
|
|
53
|
+
// non-ADR name (README.md, a draft) returns { ok: false }.
|
|
54
|
+
function parseAdrName(basename) {
|
|
55
|
+
const m = ADR_RE.exec(String(basename == null ? '' : basename));
|
|
56
|
+
if (!m) return { number: null, num: null, slug: null, ok: false };
|
|
57
|
+
return { number: m[1], num: parseInt(m[1], 10), slug: m[2], ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The 4-digit number string of an ADR basename, or null if it is not an ADR.
|
|
61
|
+
function adrNumberOf(basename) {
|
|
62
|
+
const p = parseAdrName(basename);
|
|
63
|
+
return p.ok ? p.number : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- grandfathered collisions ---------------------------------------------
|
|
67
|
+
|
|
68
|
+
// Numbers already shared by two files when this check landed (task 1003164, 2026-08-27).
|
|
69
|
+
// Frozen by EXACT filename: a third file on one of these numbers, or a rename of either
|
|
70
|
+
// half, is still a hard failure. This list may shrink (if a pair is ever consolidated) and
|
|
71
|
+
// must never grow — a new collision is the bug the check exists to catch.
|
|
72
|
+
const LEGACY_DUPLICATE_ADRS = Object.freeze({
|
|
73
|
+
'0008': Object.freeze(['0008-family-based-tile-generation.md', '0008-google-chat-oauth-user-auth.md']),
|
|
74
|
+
'0024': Object.freeze(['0024-cloneable-repo-local-first-memory.md', '0024-multi-agent-system-architecture.md']),
|
|
75
|
+
'0025': Object.freeze(['0025-offsite-backup-vendor-digitalocean-spaces.md', '0025-structured-criterion-task-link.md']),
|
|
76
|
+
'0044': Object.freeze(['0044-mediterranean-palette-replacement.md', '0044-per-box-live-game-preview.md']),
|
|
77
|
+
'0057': Object.freeze(['0057-container-cost-ledger.md', '0057-discord-archon-approval-channels.md']),
|
|
78
|
+
'0072': Object.freeze(['0072-bongos-app-mac-signed-first-windows-deferred.md', '0072-dev-box-code-staleness-visibility.md']),
|
|
79
|
+
'0073': Object.freeze(['0073-builder-needs-signal-and-byok-gemini-key.md', '0073-secrets-scan-exclude-uri-detector.md']),
|
|
80
|
+
'0082': Object.freeze(['0082-productized-key-storage-provisioning.md', '0082-server-side-merge-conflict-auto-resolution.md']),
|
|
81
|
+
'0087': Object.freeze(['0087-bongos-app-architecture-and-handoff-contract.md', '0087-compete-on-governance-not-tooling-cursor.md']),
|
|
82
|
+
'0095': Object.freeze(['0095-borrowed-memory-and-retrieval-concepts.md', '0095-cross-agent-context-management.md']),
|
|
83
|
+
'0097': Object.freeze(['0097-one-active-claim-per-session-and-worktree-binding.md', '0097-retroactive-reward-backfill.md']),
|
|
84
|
+
'0103': Object.freeze(['0103-core-first-extraction-cloud-bongos-trunk.md', '0103-gdsv4-bongos-consolidation.md']),
|
|
85
|
+
'0144': Object.freeze(['0144-devbox-rehome-onto-cloudbongos-plane.md', '0144-federated-single-logout-backchannel.md']),
|
|
86
|
+
'0145': Object.freeze(['0145-devbox-app-branding-driven-module.md', '0145-free-hosted-project-tier-isolation-and-domain-separation.md']),
|
|
87
|
+
'0152': Object.freeze(['0152-landing-gate-served-by-the-core-app.md', '0152-metic-task-abandonment.md']),
|
|
88
|
+
'0167': Object.freeze(['0167-gate-trust-link-agpl-default.md', '0167-module-catalog-source-vs-provenance.md']),
|
|
89
|
+
'0172': Object.freeze(['0172-editable-rank-roles-substrate-axis.md', '0172-per-craft-compensation-ideator-credit-lane.md']),
|
|
90
|
+
'0184': Object.freeze(['0184-a-task-visual-inherits-its-summary-audience.md', '0184-ship-requires-an-assurance.md']),
|
|
91
|
+
'0187': Object.freeze(['0187-collab-four-decisions.md', '0187-oauth-handshake-cookie-lifetime.md']),
|
|
92
|
+
'0192': Object.freeze(['0192-a-category-orients-and-authorises-nothing.md', '0192-platform-visibility-member-door.md']),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Is this exact set of files the frozen legacy pair for that number? Set-equality, so
|
|
96
|
+
// adding a third file or renaming either half stops being grandfathered.
|
|
97
|
+
function isGrandfathered(number, files) {
|
|
98
|
+
const legacy = LEGACY_DUPLICATE_ADRS[number];
|
|
99
|
+
if (!legacy) return false;
|
|
100
|
+
if (files.length !== legacy.length) return false;
|
|
101
|
+
const a = [...files].sort();
|
|
102
|
+
const b = [...legacy].sort();
|
|
103
|
+
return a.every((f, i) => f === b[i]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- collision detector ----------------------------------------------------
|
|
107
|
+
|
|
108
|
+
// Given ADR basenames, return [{ number, files[] }] for every number claimed by more than
|
|
109
|
+
// one file, EXCLUDING the frozen legacy pairs. The direct analogue of
|
|
110
|
+
// migration-namespace.js stemCollisions().
|
|
111
|
+
function numberCollisions(basenames) {
|
|
112
|
+
const byNumber = new Map();
|
|
113
|
+
for (const name of basenames || []) {
|
|
114
|
+
const p = parseAdrName(name);
|
|
115
|
+
if (!p.ok) continue;
|
|
116
|
+
if (!byNumber.has(p.number)) byNumber.set(p.number, new Set());
|
|
117
|
+
byNumber.get(p.number).add(name);
|
|
118
|
+
}
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const [number, set] of byNumber) {
|
|
121
|
+
const files = [...set].sort();
|
|
122
|
+
if (files.length > 1 && !isGrandfathered(number, files)) out.push({ number, files });
|
|
123
|
+
}
|
|
124
|
+
return out.sort((a, b) => a.number.localeCompare(b.number));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The next free ADR number (integer): max + 1, 1 on an empty tree. Mirrors next-number.js
|
|
128
|
+
// nextFree so a caller can allocate without reaching for git.
|
|
129
|
+
function nextAdrNumber(basenames) {
|
|
130
|
+
let max = 0;
|
|
131
|
+
for (const name of basenames || []) {
|
|
132
|
+
const p = parseAdrName(name);
|
|
133
|
+
if (p.ok && p.num > max) max = p.num;
|
|
134
|
+
}
|
|
135
|
+
return max + 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- index (docs/adr/README.md) --------------------------------------------
|
|
139
|
+
|
|
140
|
+
// An index row: `| NNNN | [Title](NNNN-slug.md) | topic |`. The leading cell may carry a
|
|
141
|
+
// disambiguating suffix (`0008-a`) for a grandfathered pair, so the number is read from
|
|
142
|
+
// the leading cell and the row's OWN file from the link target that starts with it — row
|
|
143
|
+
// prose cites other ADRs by link too, and only the own-number target is the row's subject.
|
|
144
|
+
const INDEX_ROW_RE = /^\|\s*(\d{4})(?:-[a-z])?\s*\|/;
|
|
145
|
+
const INDEX_LINK_RE = /\]\((\d{4}-[^)]*\.md)\)/g;
|
|
146
|
+
|
|
147
|
+
// Parse README text into [{ number, target, targets, lineNo, ok }]. `target` is the row's
|
|
148
|
+
// own ADR filename, or null when the row names none (or names more than one of its own
|
|
149
|
+
// number, which is ambiguous and reported as a violation).
|
|
150
|
+
function parseIndexRows(readmeText) {
|
|
151
|
+
const rows = [];
|
|
152
|
+
const lines = String(readmeText == null ? '' : readmeText).split('\n');
|
|
153
|
+
lines.forEach((line, i) => {
|
|
154
|
+
const m = INDEX_ROW_RE.exec(line);
|
|
155
|
+
if (!m) return;
|
|
156
|
+
const number = m[1];
|
|
157
|
+
INDEX_LINK_RE.lastIndex = 0;
|
|
158
|
+
const own = [...line.matchAll(INDEX_LINK_RE)].map((x) => x[1]).filter((t) => t.startsWith(number + '-'));
|
|
159
|
+
rows.push({ number, target: own.length === 1 ? own[0] : null, targets: own, lineNo: i + 1, ok: own.length === 1 });
|
|
160
|
+
});
|
|
161
|
+
return rows;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The index invariant, checked by LINK TARGET: every ADR file has exactly one row, and
|
|
165
|
+
// every row points at a file that exists. Returns violation strings.
|
|
166
|
+
function indexViolations({ basenames = [], rows = [] } = {}) {
|
|
167
|
+
const violations = [];
|
|
168
|
+
const files = new Set((basenames || []).filter((n) => parseAdrName(n).ok));
|
|
169
|
+
|
|
170
|
+
const rowsByTarget = new Map();
|
|
171
|
+
for (const r of rows) {
|
|
172
|
+
if (!r.ok) {
|
|
173
|
+
violations.push(
|
|
174
|
+
`docs/adr/README.md:${r.lineNo}: the index row for ${r.number} names ${r.targets.length} link(s) to its own number `
|
|
175
|
+
+ `(${r.targets.join(', ') || 'none'}) — a row must link its own ADR file exactly once so the index can be checked against the tree.`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!rowsByTarget.has(r.target)) rowsByTarget.set(r.target, []);
|
|
179
|
+
rowsByTarget.get(r.target).push(r.lineNo);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [target, lineNos] of [...rowsByTarget.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
183
|
+
if (!files.has(target)) {
|
|
184
|
+
violations.push(
|
|
185
|
+
`docs/adr/README.md:${lineNos[0]}: index row points at docs/adr/${target}, which does not exist — a dangling index row `
|
|
186
|
+
+ `(the other half of the collision problem: a merge can leave a row behind after the file it named moved or was renumbered).`);
|
|
187
|
+
}
|
|
188
|
+
if (lineNos.length > 1) {
|
|
189
|
+
violations.push(
|
|
190
|
+
`docs/adr/README.md: ${target} has ${lineNos.length} index rows (lines ${lineNos.join(', ')}) — exactly one row per ADR; `
|
|
191
|
+
+ 'a duplicated row is what an auto-merged index leaves behind.');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const f of [...files].sort()) {
|
|
196
|
+
if (!rowsByTarget.has(f)) {
|
|
197
|
+
violations.push(
|
|
198
|
+
`docs/adr/${f} has no row in docs/adr/README.md — every ADR is indexed. (This is how a number collision hides: the `
|
|
199
|
+
+ 'second file of a colliding pair is the one that never gets indexed, so the tree and the index quietly disagree.)');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return violations;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Rows should read in ascending number order. WARNING only, never a hard failure: the live
|
|
207
|
+
// index carries legacy rows an auto-merge placed out of order, and blocking on history
|
|
208
|
+
// would fail every PR for a mess nobody's diff created (the ADR 0189 split — declaration
|
|
209
|
+
// rot hard-fails, inherited drift warns).
|
|
210
|
+
function indexOrderWarnings(rows) {
|
|
211
|
+
const warnings = [];
|
|
212
|
+
let prev = null;
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
if (prev !== null && r.number < prev) {
|
|
215
|
+
warnings.push(`docs/adr/README.md:${r.lineNo}: row ${r.number} sits after ${prev} — the index reads in ascending order.`);
|
|
216
|
+
}
|
|
217
|
+
if (prev === null || r.number > prev) prev = r.number;
|
|
218
|
+
}
|
|
219
|
+
return warnings;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- the fitness check -----------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const ADR_DIR = path.join(CORE_ROOT, 'docs', 'adr');
|
|
225
|
+
|
|
226
|
+
function adrFilesIn(absDir) {
|
|
227
|
+
try { return fs.readdirSync(absDir).filter((f) => parseAdrName(f).ok).sort(); }
|
|
228
|
+
catch { return []; } // no docs/adr in a vanilla instance → nothing to check
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check 31 — two ADRs may not share a number, and the index must agree with the tree.
|
|
232
|
+
function checkAdrNumbering() {
|
|
233
|
+
const files = adrFilesIn(ADR_DIR);
|
|
234
|
+
const violations = [];
|
|
235
|
+
|
|
236
|
+
for (const c of numberCollisions(files)) {
|
|
237
|
+
violations.push(
|
|
238
|
+
`ADR number ${c.number} is claimed by ${c.files.length} files (${c.files.map((f) => `docs/adr/${f}`).join(', ')}) — `
|
|
239
|
+
+ 'an ADR number is a citation target, so a duplicate makes every citation to it ambiguous forever. Renumber the NEWER '
|
|
240
|
+
+ 'file to the next free number (node scripts/gds/next-number.js) and move its README row with it.');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let readme = null;
|
|
244
|
+
try { readme = fs.readFileSync(path.join(ADR_DIR, 'README.md'), 'utf8'); } catch { /* no index → nothing to reconcile */ }
|
|
245
|
+
const rows = readme === null ? [] : parseIndexRows(readme);
|
|
246
|
+
if (readme !== null) violations.push(...indexViolations({ basenames: files, rows }));
|
|
247
|
+
const warnings = readme === null ? [] : indexOrderWarnings(rows);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
name: 'ADR numbers unique + index agrees with the tree (task 1003164)',
|
|
251
|
+
ok: violations.length === 0,
|
|
252
|
+
hardFail: violations.length > 0,
|
|
253
|
+
violations,
|
|
254
|
+
warnings,
|
|
255
|
+
note: `${files.length} ADR file(s), ${rows.length} index row(s), `
|
|
256
|
+
+ `${Object.keys(LEGACY_DUPLICATE_ADRS).length} grandfathered duplicate number(s) (frozen by exact filename — the list may only shrink). `
|
|
257
|
+
+ `Next free number: ${String(nextAdrNumber(files)).padStart(4, '0')}.`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
ADR_RE,
|
|
263
|
+
LEGACY_DUPLICATE_ADRS,
|
|
264
|
+
parseAdrName,
|
|
265
|
+
adrNumberOf,
|
|
266
|
+
isGrandfathered,
|
|
267
|
+
numberCollisions,
|
|
268
|
+
nextAdrNumber,
|
|
269
|
+
parseIndexRows,
|
|
270
|
+
indexViolations,
|
|
271
|
+
indexOrderWarnings,
|
|
272
|
+
checkAdrNumbering,
|
|
273
|
+
};
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1319,6 +1319,12 @@ const CHECKS = [
|
|
|
1319
1319
|
// doc-comment gate that blocks a ship buys filler prose, not understanding.
|
|
1320
1320
|
// Making it blocking is an owner decision and needs its own ADR.
|
|
1321
1321
|
require('./doc-comment-coverage.js').checkDocCommentCoverage,
|
|
1322
|
+
// Check 31 — task 1003164: two ADRs may not share a number, and docs/adr/README.md
|
|
1323
|
+
// must agree with the tree. The ADR half of the guard Check 11b already gives
|
|
1324
|
+
// migrations: same shared, monotonic, human-allocated number line, and until this
|
|
1325
|
+
// landed only one of the two was enforced. An ADR number is a citation target, so a
|
|
1326
|
+
// duplicate is wrong forever (ADR 0195).
|
|
1327
|
+
require('./adr-namespace.js').checkAdrNumbering,
|
|
1322
1328
|
];
|
|
1323
1329
|
|
|
1324
1330
|
function runAll() {
|
|
@@ -1364,6 +1370,7 @@ module.exports = {
|
|
|
1364
1370
|
checkKernelImportsNoDomain,
|
|
1365
1371
|
checkModuleMigrationsAdditive,
|
|
1366
1372
|
checkMigrationNamespacing, // Check 11b — ADR 0108 §5 cross-repo migration filenames
|
|
1373
|
+
checkAdrNumbering: require('./adr-namespace.js').checkAdrNumbering, // Check 31 — task 1003164 / ADR 0195: ADR numbers unique + index agrees with the tree
|
|
1367
1374
|
checkDesignContract,
|
|
1368
1375
|
checkKernelPromptsNoHardcodedIdentity, // BV1.R76 / task 1705
|
|
1369
1376
|
scanPromptIdentity, // BV1.R76 — negative-test hook
|
|
@@ -14,6 +14,10 @@ const os = require('node:os');
|
|
|
14
14
|
const { cliClient, rewardPolicy } = require('./cli-lib');
|
|
15
15
|
const { gitOk } = require('./ship-git.js');
|
|
16
16
|
const { resolveInstanceBase } = require('./ship-deploy-target.js');
|
|
17
|
+
// task 1003313: the shared onboarding/doctor preflight — reused (not duplicated)
|
|
18
|
+
// so the grader outage explains itself with the same `claude CLI` verdict doctor
|
|
19
|
+
// gives, and the two can never drift.
|
|
20
|
+
const { preflightChecks } = require('./preflight.js');
|
|
17
21
|
const { computeRawDiffHash, reconstructPanelIssues, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
|
|
18
22
|
|
|
19
23
|
// ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
|
|
@@ -223,7 +227,33 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
|
|
|
223
227
|
// issues + next-step hints). Pure + exported so the #943 grader-unavailable vs
|
|
224
228
|
// FAIL branch is unit-tested without capturing stdout. printGradeOutcome just
|
|
225
229
|
// prints what this returns.
|
|
226
|
-
|
|
230
|
+
// task 1003313: the per-worker diagnostic lines for a total outage, plus the
|
|
231
|
+
// local `claude` CLI verdict. Pure over its inputs (cliFinding is INJECTED, never
|
|
232
|
+
// spawned here) so formatGradeOutcome stays unit-testable without a subprocess.
|
|
233
|
+
function formatOutageDiagnostic(g, cliFinding) {
|
|
234
|
+
const out = [];
|
|
235
|
+
const dead = (g.signals?.workers || []).filter((w) => w && w.error);
|
|
236
|
+
const detailed = dead.filter((w) => w.exit_code != null || w.stderr_tail);
|
|
237
|
+
if (detailed.length) {
|
|
238
|
+
out.push('');
|
|
239
|
+
out.push('What the grader subprocess actually did:');
|
|
240
|
+
for (const w of detailed) {
|
|
241
|
+
const code = w.exit_code != null ? `exit ${w.exit_code}` : w.error;
|
|
242
|
+
out.push(` ${String(w.kind || '?').padEnd(11)} ${code}${w.stderr_tail ? ` — ${w.stderr_tail.split('\n')[0]}` : ''}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (cliFinding && cliFinding.level !== 'ok') {
|
|
246
|
+
out.push('');
|
|
247
|
+
out.push(`Local \`claude\` CLI: ${cliFinding.detail}`);
|
|
248
|
+
if (cliFinding.fix) out.push(` Fix: ${cliFinding.fix}`);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// cliFinding: the preflight `claude CLI` row, injected by printGradeOutcome so a
|
|
254
|
+
// dark panel can say "your CLI is logged out" instead of leaving the builder to
|
|
255
|
+
// guess. Report-only — it never changes the outcome (ADR 0162: no new gates).
|
|
256
|
+
function formatGradeOutcome(g, taskId, cliFinding = null) {
|
|
227
257
|
const out = [];
|
|
228
258
|
// #943: a TOTAL grader outage (every gating worker failed to spawn / the
|
|
229
259
|
// grader was bypassed pre-spawn) is NOT a quality rejection — it's "the grader
|
|
@@ -236,6 +266,7 @@ function formatGradeOutcome(g, taskId) {
|
|
|
236
266
|
out.push('');
|
|
237
267
|
out.push(`Subagent grade: GRADER UNAVAILABLE${reason ? ` (${reason})` : ''} · no quality signal produced — NOT a quality rejection`);
|
|
238
268
|
if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
|
|
269
|
+
out.push(...formatOutageDiagnostic(g, cliFinding));
|
|
239
270
|
out.push('');
|
|
240
271
|
out.push('The grader could not run (infra/outage/bypass) — there are no findings to fix. Task stays at completed.');
|
|
241
272
|
out.push('When the grader is reachable again, re-grade:');
|
|
@@ -308,7 +339,17 @@ function formatGradeOutcome(g, taskId) {
|
|
|
308
339
|
// last resort — see feedback_prefer_regrade_over_override). #943: a total grader
|
|
309
340
|
// outage prints as "GRADER UNAVAILABLE", not a 0/10 FAIL — see formatGradeOutcome.
|
|
310
341
|
function printGradeOutcome(g, taskId) {
|
|
311
|
-
|
|
342
|
+
// task 1003313: on a TOTAL outage only, ask the existing preflight whether the
|
|
343
|
+
// local `claude` CLI is launchable and logged in — the two faults that produce
|
|
344
|
+
// this exact all-workers-dead signature. Report-only and outage-only, so the
|
|
345
|
+
// happy path pays nothing and no ship is newly gated (ADR 0162).
|
|
346
|
+
let cliFinding = null;
|
|
347
|
+
if (g?.signals?.panel_outcome === 'unavailable') {
|
|
348
|
+
try {
|
|
349
|
+
cliFinding = preflightChecks().find((r) => r.title === 'claude CLI') || null;
|
|
350
|
+
} catch (_) { /* best-effort: a broken probe must never mask the grade output */ }
|
|
351
|
+
}
|
|
352
|
+
console.log(formatGradeOutcome(g, taskId, cliFinding));
|
|
312
353
|
}
|
|
313
354
|
|
|
314
355
|
// Print the credits line + multiplier breakdown + idea bonus + unlocked
|
package/src/module-api.js
CHANGED
|
@@ -1206,8 +1206,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1206
1206
|
// landed since 1.19.381 with no explicit bump. run 33094545251. (task 1002620)
|
|
1207
1207
|
// 1.19.383 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1208
1208
|
// landed since 1.19.382 with no explicit bump. run 33101389971. (task 1002620)
|
|
1209
|
+
// 1.19.384 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1210
|
+
// landed since 1.19.383 with no explicit bump. run 33116184189. (task 1002620)
|
|
1211
|
+
// 1.19.385 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1212
|
+
// landed since 1.19.384 with no explicit bump. run 33118682829. (task 1002620)
|
|
1209
1213
|
// ---------------------------------------------------------------------------
|
|
1210
|
-
const CORE_VERSION = '1.19.
|
|
1214
|
+
const CORE_VERSION = '1.19.385'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1211
1215
|
|
|
1212
1216
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1213
1217
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|