@bongos/core 1.19.1033 → 1.19.1034
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 +186 -86
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +1 -1
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +70 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
package/scripts/gds/dev.js
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
// * PORT is forced to branding's port (the server ignores branding — platform-server.js:44).
|
|
27
27
|
// * PGDATABASE is set for BOTH migrate.sh and the server (fixes the pool.js/migrate.sh
|
|
28
28
|
// default split); PGHOST defaults to TCP localhost (not the Linux peer socket).
|
|
29
|
+
// * Windows (task 1002707) runs the same steps: npm via its own entry point, the
|
|
30
|
+
// migrations in Git Bash, the Postgres installer's bin folder and `postgres` role.
|
|
31
|
+
// Every Windows decision is a pure function in dev-lib.js's WINDOWS section.
|
|
29
32
|
|
|
30
33
|
const cp = require('node:child_process');
|
|
31
34
|
const fs = require('node:fs');
|
|
@@ -70,7 +73,8 @@ Usage:
|
|
|
70
73
|
|
|
71
74
|
What it does (idempotent — safe to re-run):
|
|
72
75
|
1. Install dependencies if this checkout has none
|
|
73
|
-
2. Make sure Postgres is running (Homebrew on a Mac, systemd/service on a host
|
|
76
|
+
2. Make sure Postgres is running (Homebrew on a Mac, systemd/service on a host,
|
|
77
|
+
the Postgres Windows service on Windows)
|
|
74
78
|
3. Create this instance's database if it doesn't exist
|
|
75
79
|
4. Build/refresh its tables (migrations)
|
|
76
80
|
5. Start the server on the port in config/branding.json
|
|
@@ -84,33 +88,44 @@ What --server changes:
|
|
|
84
88
|
• it checks the PUBLIC origin, not just loopback, and never opens a browser
|
|
85
89
|
Extra flags: --bind <addr>, --user <unix-user> (who the service runs as).
|
|
86
90
|
|
|
87
|
-
Needs Postgres + Node >= 22 installed. macOS
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
Needs Postgres + Node >= 22 installed. Runs on macOS, Linux and Windows.
|
|
92
|
+
On Windows the migrations run in Git Bash (it comes with Git for Windows), and
|
|
93
|
+
bongos dev connects to Postgres as "postgres" with the password you chose when
|
|
94
|
+
installing it (set PGPASSWORD). Fallback anywhere: the Docker path in docs/recipes/self-host.md.
|
|
95
|
+
Walkthroughs: docs/recipes/local-dev.md, docs/recipes/server-bring-up.md`);
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
// psql/createdb
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (params.pgUser) a.push('-U', params.pgUser);
|
|
96
|
-
return a;
|
|
97
|
-
}
|
|
98
|
+
// psql/createdb connection flags (dev-lib.pgClientArgs decides; -w on Windows so a
|
|
99
|
+
// password prompt can never hang a run).
|
|
100
|
+
function pgArgs(params, opts) { return dev.pgClientArgs(params, opts); }
|
|
98
101
|
|
|
99
102
|
function pgReady(params) {
|
|
100
|
-
return run('pg_isready', [...pgArgs(params), '-q']).status === 0;
|
|
103
|
+
return run('pg_isready', [...pgArgs(params, { forReady: true }), '-q']).status === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Every path `where` prints for a binary (Windows lists all matches, in PATH order).
|
|
107
|
+
function whereAll(bin) {
|
|
108
|
+
if (process.platform !== 'win32') return [];
|
|
109
|
+
return run('where', [bin]).stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The Postgres installs the Windows installer left under Program Files ([] elsewhere).
|
|
113
|
+
function pgInstalls() {
|
|
114
|
+
return process.platform === 'win32' ? dev.windowsPgInstalls() : [];
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
// Am I root? (Only meaningful on POSIX; Windows has no getuid.)
|
|
104
118
|
function isRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; }
|
|
105
119
|
|
|
106
120
|
// Best-effort: start whatever service manager owns Postgres on this platform
|
|
107
|
-
// (Homebrew on a Mac, systemd/service on a Linux host,
|
|
108
|
-
// anywhere), then poll until it accepts connections.
|
|
109
|
-
// prefixing are decided by dev-lib.pgStartPlan; this just
|
|
121
|
+
// (Homebrew on a Mac, systemd/service on a Linux host, the installer's service on
|
|
122
|
+
// Windows, pg_ctl with PGDATA anywhere), then poll until it accepts connections.
|
|
123
|
+
// The candidate list + sudo prefixing are decided by dev-lib.pgStartPlan; this just
|
|
124
|
+
// executes them in order.
|
|
110
125
|
function ensurePostgres(params) {
|
|
111
126
|
if (pgReady(params)) { ok('Postgres is running'); return true; }
|
|
112
127
|
info('Postgres is not running — trying to start it…');
|
|
113
|
-
for (const step of dev.pgStartPlan({ has, isRoot: isRoot() })) {
|
|
128
|
+
for (const step of dev.pgStartPlan({ has, isRoot: isRoot(), pgInstalls: pgInstalls() })) {
|
|
114
129
|
let args = step.args;
|
|
115
130
|
if (step.needsFormula) {
|
|
116
131
|
// Homebrew's formula name has to be discovered at runtime (postgresql@16,
|
|
@@ -177,13 +192,16 @@ function createDb(params) {
|
|
|
177
192
|
if (/already exists/i.test(r.stderr)) { ok(`database "${params.dbName}" exists`); return true; }
|
|
178
193
|
if (needsRoleBootstrap(r.stderr) && process.platform === 'linux') return bootstrapRole(params);
|
|
179
194
|
fail(`could not create database "${params.dbName}": ${tail(r.stderr, 2) || r.error}`);
|
|
195
|
+
for (const line of dev.pgAuthHint(process.platform, r.stderr)) console.error(` ${line}`);
|
|
180
196
|
return false;
|
|
181
197
|
}
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
199
|
+
// Run migrate.sh under `bashBin` (resolved by preflight: plain `bash` on Mac/Linux,
|
|
200
|
+
// Git Bash's bash.exe on Windows).
|
|
201
|
+
function migrate(params, bashBin = 'bash') {
|
|
202
|
+
const migrateSh = dev.bashScriptPath(path.join(params.coreRoot, 'scripts', 'migrate.sh'));
|
|
185
203
|
const env = dev.buildEnv(params, { role: 'migrate' });
|
|
186
|
-
const r = run(
|
|
204
|
+
const r = run(bashBin, [migrateSh], { cwd: params.instanceRoot, env });
|
|
187
205
|
if (r.status !== 0) {
|
|
188
206
|
fail('migrations failed:');
|
|
189
207
|
console.error(tail(r.stderr || r.stdout, 12));
|
|
@@ -469,54 +487,86 @@ async function cmdServeStart(params) {
|
|
|
469
487
|
return 0;
|
|
470
488
|
}
|
|
471
489
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// truly-bare case where not even the `bongos` bin exists yet).
|
|
490
|
+
// Step 0: dependencies. A freshly-cloned instance has no node_modules, so
|
|
491
|
+
// migrate.sh (pg) and the server (pg/express) would crash. Install FIRST, so
|
|
492
|
+
// `bongos dev` is a true one-command bring-up even from a bare checkout (task
|
|
493
|
+
// 1002405 — pairs with the root START-HERE that names `npm install` for the
|
|
494
|
+
// truly-bare case where not even the `bongos` bin exists yet). How npm is started
|
|
495
|
+
// is dev-lib.npmInvocation's call: on Windows a plain spawn of `npm` fails.
|
|
496
|
+
function installDeps(params) {
|
|
480
497
|
const depsPlan = dev.depsInstallPlan({ instanceRoot: params.instanceRoot });
|
|
481
|
-
if (depsPlan.needed)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
info(`installing dependencies (npm ${depsPlan.cmd}) — first run in this checkout, this can take a minute…`);
|
|
487
|
-
const r = run('npm', depsPlan.args, { cwd: params.instanceRoot, stdio: 'inherit' });
|
|
488
|
-
if (r.status !== 0) {
|
|
489
|
-
fail(`\`npm ${depsPlan.cmd}\` failed (exit ${r.status ?? '?'}). Fix the error above, then re-run \`bongos dev\`.`);
|
|
490
|
-
return 1;
|
|
491
|
-
}
|
|
492
|
-
ok('dependencies installed');
|
|
498
|
+
if (!depsPlan.needed) return true;
|
|
499
|
+
if (!has('npm')) {
|
|
500
|
+
fail('node_modules is missing and `npm` is not on PATH — install Node (it bundles npm), then re-run `bongos dev`.');
|
|
501
|
+
return false;
|
|
493
502
|
}
|
|
503
|
+
info(`installing dependencies (npm ${depsPlan.cmd}) — first run in this checkout, this can take a minute…`);
|
|
504
|
+
const npm = dev.npmInvocation(depsPlan.args);
|
|
505
|
+
const r = run(npm.cmd, npm.args, { cwd: params.instanceRoot, stdio: 'inherit', shell: npm.shell });
|
|
506
|
+
if (r.status !== 0) {
|
|
507
|
+
fail(`\`npm ${depsPlan.cmd}\` failed (exit ${r.status ?? '?'}). Fix the error above, then re-run \`bongos dev\`.`);
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
ok('dependencies installed');
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Windows: the Postgres installer leaves psql & co. OFF PATH. Put the newest
|
|
515
|
+
// installed bin folder on PATH for this run (children inherit it), so an installed
|
|
516
|
+
// Postgres is found without anyone editing PATH. Returns the install used, or null.
|
|
517
|
+
function addWindowsPgToPath() {
|
|
518
|
+
if (process.platform !== 'win32') return null;
|
|
519
|
+
const inst = pgInstalls()[0];
|
|
520
|
+
if (!inst) return null;
|
|
521
|
+
process.env.PATH = `${inst.bin}${path.win32.delimiter}${process.env.PATH || ''}`;
|
|
522
|
+
return inst;
|
|
523
|
+
}
|
|
494
524
|
|
|
495
|
-
|
|
496
|
-
|
|
525
|
+
// Preflight: Postgres client tools + a bash for migrations. Returns
|
|
526
|
+
// { ok: true, bashBin } or { ok: false } after printing what to do, per platform.
|
|
527
|
+
function preflight({ skipMigrate = false } = {}) {
|
|
528
|
+
const PG_TOOLS = ['psql', 'createdb', 'pg_isready'];
|
|
529
|
+
let missing = PG_TOOLS.filter((b) => !has(b));
|
|
530
|
+
if (missing.length) {
|
|
531
|
+
const inst = addWindowsPgToPath();
|
|
532
|
+
if (inst) {
|
|
533
|
+
missing = PG_TOOLS.filter((b) => !has(b));
|
|
534
|
+
if (!missing.length) ok(`using the Postgres tools in ${inst.bin}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
497
537
|
if (missing.length) {
|
|
498
538
|
fail(`Postgres client tools missing: ${missing.join(', ')}`);
|
|
499
539
|
console.error(' Install Postgres, then re-run `bongos dev`:');
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
: ' (Debian/Ubuntu) sudo apt-get install -y postgresql (or your distro’s package)');
|
|
503
|
-
return 1;
|
|
540
|
+
for (const line of dev.pgInstallHint(process.platform)) console.error(` ${line}`);
|
|
541
|
+
return { ok: false };
|
|
504
542
|
}
|
|
505
|
-
if (
|
|
543
|
+
if (skipMigrate) return { ok: true, bashBin: null };
|
|
544
|
+
const bash = dev.resolveBash({ has, whereAll });
|
|
545
|
+
if (!bash.ok) {
|
|
506
546
|
fail('`bash` is required to run migrations (used by scripts/migrate.sh).');
|
|
507
|
-
|
|
508
|
-
return
|
|
547
|
+
for (const line of dev.bashMissingHint(process.platform, { wslOnly: bash.wslOnly })) console.error(` ${line}`);
|
|
548
|
+
return { ok: false };
|
|
509
549
|
}
|
|
550
|
+
if (process.platform === 'win32') ok(`migrations will run in ${bash.bin}`);
|
|
551
|
+
return { ok: true, bashBin: bash.bin };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function cmdUp(params, { foreground, noLogin, skipMigrate }) {
|
|
555
|
+
info(`Standing up ${path.basename(params.instanceRoot)} on ${params.publicOrigin}`);
|
|
556
|
+
|
|
557
|
+
if (!installDeps(params)) return 1;
|
|
558
|
+
const pre = preflight({ skipMigrate });
|
|
559
|
+
if (!pre.ok) return 1;
|
|
510
560
|
|
|
511
561
|
if (!ensurePostgres(params)) {
|
|
512
562
|
fail('Postgres is installed but not reachable, and I could not start it.');
|
|
513
563
|
console.error(` Start it yourself, then re-run \`bongos dev\`:`);
|
|
514
|
-
|
|
564
|
+
for (const line of dev.pgStartHint(process.platform, { pgInstalls: pgInstalls() })) console.error(` ${line}`);
|
|
515
565
|
return 1;
|
|
516
566
|
}
|
|
517
567
|
if (!createDb(params)) return 1;
|
|
518
568
|
if (skipMigrate) warn('skipping migrations (--skip-migrate)');
|
|
519
|
-
else if (!migrate(params)) return 1;
|
|
569
|
+
else if (!migrate(params, pre.bashBin)) return 1;
|
|
520
570
|
|
|
521
571
|
const authReady = dev.authWillWork();
|
|
522
572
|
if (authReady) ok('sign-in configured');
|
|
@@ -590,4 +640,6 @@ if (require.main === module) {
|
|
|
590
640
|
main().then((code) => process.exit(code || 0)).catch((e) => { fail(e && e.message ? e.message : String(e)); process.exit(1); });
|
|
591
641
|
}
|
|
592
642
|
|
|
593
|
-
|
|
643
|
+
// installDeps / preflight / migrate / createDb are exported for tests/dev_windows.mjs,
|
|
644
|
+
// which drives the win32 branches with process.platform and spawnSync stubbed.
|
|
645
|
+
module.exports = { usage, pgArgs, installDeps, preflight, migrate, createDb };
|
package/scripts/gds/doctor.js
CHANGED
|
@@ -30,6 +30,7 @@ const { execSync, spawnSync } = require('node:child_process');
|
|
|
30
30
|
const { cliClient, SESSION_PATH, loadSession } = require('./cli-lib');
|
|
31
31
|
const { classifyMergeDriverState, MERGE_DRIVER_NAME } = require('./install-git-hooks.js');
|
|
32
32
|
const { preflightChecks } = require('./preflight');
|
|
33
|
+
const { resolveEnv } = require('../../src/instance-config');
|
|
33
34
|
|
|
34
35
|
// ANSI helpers (only if stdout is a TTY; otherwise emit plain text)
|
|
35
36
|
const TTY = process.stdout.isTTY;
|
|
@@ -126,7 +127,7 @@ async function checkSession() {
|
|
|
126
127
|
// this bit a real builder twice and was only diagnosed after a long manual dig.
|
|
127
128
|
// This check makes it the FIRST thing doctor reports, with the one-line fix.
|
|
128
129
|
//
|
|
129
|
-
// Resolution mirrors the grader EXACTLY (
|
|
130
|
+
// Resolution mirrors the grader EXACTLY (<PREFIX>_CLAUDE_BIN / CLAUDE_BIN / 'claude',
|
|
130
131
|
// shell:true on win32 so a `claude.cmd` shim resolves) so a green row here means
|
|
131
132
|
// the grader's spawn will work too. Mac/Linux: shell:false, identical to today.
|
|
132
133
|
function checkToolchain() {
|
|
@@ -152,7 +153,7 @@ function checkToolchain() {
|
|
|
152
153
|
// can pass --version yet misbehave under the grader's stdio redirection
|
|
153
154
|
// (SUBAGENT_NONZERO_EXIT). Learning #42.
|
|
154
155
|
const claudeOk = results.some((r) => r.title === 'claude CLI' && r.level === 'ok');
|
|
155
|
-
if (claudeOk && process.platform === 'win32' && !
|
|
156
|
+
if (claudeOk && process.platform === 'win32' && !resolveEnv('CLAUDE_BIN') && !process.env.CLAUDE_BIN) {
|
|
156
157
|
try {
|
|
157
158
|
const where = spawnSync('where', ['claude'], { encoding: 'utf8', shell: true, timeout: 8000 });
|
|
158
159
|
if (where.status === 0 && /WindowsApps/i.test(where.stdout || '')) {
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/grade-correlation-audit.js — do the grader's panel workers fail
|
|
5
|
+
// DIFFERENTLY, or are we paying four times for two votes? (task 1002843,
|
|
6
|
+
// ADR 0089 §5 — the structural answer to "Nine Judges, Two Effective Votes").
|
|
7
|
+
//
|
|
8
|
+
// WHY. Aggregation cannot manufacture independence a panel does not have. ADR
|
|
9
|
+
// 0089 therefore makes panel composition a measured choice, and nothing measured
|
|
10
|
+
// it: /grade-audit and /grader-health are qualitative sweeps with no inter-judge
|
|
11
|
+
// statistics. This reads PERSISTED grades — never re-grades, never spawns a
|
|
12
|
+
// model, never writes anything — and reports:
|
|
13
|
+
// (a) pairwise agreement between the panel workers on the same task: raw
|
|
14
|
+
// agreement, Cohen's kappa (agreement corrected for chance) and the phi
|
|
15
|
+
// correlation of their objections;
|
|
16
|
+
// (b) each worker's Cohen's kappa against the OWNER'S decisions;
|
|
17
|
+
// (c) which workers look redundant with another and which carry independent
|
|
18
|
+
// signal, plus the panel's effective number of independent votes.
|
|
19
|
+
//
|
|
20
|
+
// A VOTE is binary: a worker OBJECTS (verdict concerns or fail) or CLEARS (pass).
|
|
21
|
+
// A worker that errored without a finding produced no signal and casts no vote
|
|
22
|
+
// on that task — the same missing-signal rule the gate applies (#473). The
|
|
23
|
+
// deterministic pseudo-judges and the non-gating reader are not voters.
|
|
24
|
+
//
|
|
25
|
+
// THE OWNER'S DECISIONS — the only labels used, per the task:
|
|
26
|
+
// accept an approved override request, or a manual confirm of work whose
|
|
27
|
+
// stored grade did not pass (task confirmed/shipped anyway);
|
|
28
|
+
// reject a denied override request.
|
|
29
|
+
// A passing grade that auto-landed is NOT a label: that is the grader agreeing
|
|
30
|
+
// with itself, and counting it would inflate every worker's kappa. The labelled
|
|
31
|
+
// set is therefore a selected one — tasks the panel did not pass, plus the ones
|
|
32
|
+
// someone argued with — and the report says so.
|
|
33
|
+
//
|
|
34
|
+
// THE MINIMUM-N GUARD. Every statistic is REFUSED, with the reason, below
|
|
35
|
+
// MIN_N observations or when either side has fewer than MIN_PER_CLASS of a
|
|
36
|
+
// class (a kappa over a constant rater is undefined, and one over five labels is
|
|
37
|
+
// noise). --min-n may raise the bar, never lower it. Report-only: no gate reads
|
|
38
|
+
// this, and nothing changes status (ADR 0162).
|
|
39
|
+
//
|
|
40
|
+
// Usage:
|
|
41
|
+
// node scripts/gds/grade-correlation-audit.js [--recent N] [--min-n N] [--json]
|
|
42
|
+
// pull the N most recently finished tasks' grades (default 400) + the
|
|
43
|
+
// decided override requests (Metic+ read) through the API
|
|
44
|
+
// ... --save <file> also write the pulled dataset, to re-run offline
|
|
45
|
+
// ... --file <file> audit a saved dataset instead of the live API
|
|
46
|
+
|
|
47
|
+
const fs = require('node:fs');
|
|
48
|
+
const { arg, hasFlag, cliClient } = require('./cli-lib');
|
|
49
|
+
const { fetchRecentGrades, trimRecordedGrade } = require('./grade-replay');
|
|
50
|
+
|
|
51
|
+
const PANEL = ['narc', 'quality', 'hacker', 'efficiency'];
|
|
52
|
+
const MIN_N = 30;
|
|
53
|
+
const MIN_PER_CLASS = 5;
|
|
54
|
+
const REDUNDANT_KAPPA = 0.8;
|
|
55
|
+
const INDEPENDENT_KAPPA = 0.4;
|
|
56
|
+
|
|
57
|
+
// A worker's vote on one grade: 1 objects, 0 clears, null no signal.
|
|
58
|
+
function voteOf(w) {
|
|
59
|
+
if (!w || typeof w !== 'object') return null;
|
|
60
|
+
const issues = Array.isArray(w.issues) ? w.issues : [];
|
|
61
|
+
const substantive = issues.some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
|
|
62
|
+
if (w.error && !substantive) return null;
|
|
63
|
+
if (w.verdict === 'pass') return 0;
|
|
64
|
+
if (w.verdict === 'concerns' || w.verdict === 'fail') return 1;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// { kind -> vote } for the panel workers of one grade.
|
|
69
|
+
function panelVotes(workers) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const w of Array.isArray(workers) ? workers : []) {
|
|
72
|
+
if (!w || !PANEL.includes(w.kind)) continue;
|
|
73
|
+
const v = voteOf(w);
|
|
74
|
+
if (v != null) out[w.kind] = v;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Why a binary series cannot carry a statistic, or null when it can.
|
|
80
|
+
function refusal(pairs, minN) {
|
|
81
|
+
if (pairs.length < minN) return `n=${pairs.length} is below the minimum of ${minN}`;
|
|
82
|
+
for (const side of [0, 1]) {
|
|
83
|
+
const ones = pairs.filter((p) => p[side] === 1).length;
|
|
84
|
+
const zeros = pairs.length - ones;
|
|
85
|
+
if (Math.min(ones, zeros) < MIN_PER_CLASS) {
|
|
86
|
+
return `${side === 0 ? 'the first' : 'the second'} rater has only ${Math.min(ones, zeros)} of one class (need ${MIN_PER_CLASS}+ of each)`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cohen's kappa + raw agreement + phi for [[a, b], ...] binary pairs. Pure; the
|
|
93
|
+
// caller has already applied the minimum-N guard.
|
|
94
|
+
function agreementStats(pairs) {
|
|
95
|
+
const n = pairs.length;
|
|
96
|
+
let n11 = 0; let n10 = 0; let n01 = 0; let n00 = 0;
|
|
97
|
+
for (const [a, b] of pairs) {
|
|
98
|
+
if (a === 1 && b === 1) n11++; else if (a === 1) n10++; else if (b === 1) n01++; else n00++;
|
|
99
|
+
}
|
|
100
|
+
const po = (n11 + n00) / n;
|
|
101
|
+
const pa = (n11 + n10) / n;
|
|
102
|
+
const pb = (n11 + n01) / n;
|
|
103
|
+
const pe = pa * pb + (1 - pa) * (1 - pb);
|
|
104
|
+
const kappa = pe === 1 ? null : (po - pe) / (1 - pe);
|
|
105
|
+
const den = Math.sqrt((n11 + n10) * (n01 + n00) * (n11 + n01) * (n10 + n00));
|
|
106
|
+
const phi = den === 0 ? null : (n11 * n00 - n10 * n01) / den;
|
|
107
|
+
return { n, agreement: po, kappa, phi, table: { both_object: n11, only_first: n10, only_second: n01, both_clear: n00 } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function guardedStats(pairs, minN) {
|
|
111
|
+
const why = refusal(pairs, minN);
|
|
112
|
+
return why ? { n: pairs.length, refused: why } : agreementStats(pairs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Effective number of independent votes for k raters whose mean pairwise
|
|
116
|
+
// correlation is rho (the design-effect formula). A negative mean is clamped to
|
|
117
|
+
// 0 — this is a ceiling on independence, not a claim of anti-correlation.
|
|
118
|
+
function effectiveVotes(k, rho) {
|
|
119
|
+
const r = Math.max(0, rho);
|
|
120
|
+
return k / (1 + (k - 1) * r);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The owner's decision per task id, from the override ledger and the tasks'
|
|
124
|
+
// fates. Returns Map(task_id -> { label: 'accept'|'reject', source }). An
|
|
125
|
+
// approval outranks a denial on the same task: it was the later decision.
|
|
126
|
+
function ownerLabels({ grades, overrides }) {
|
|
127
|
+
const labels = new Map();
|
|
128
|
+
for (const o of overrides || []) {
|
|
129
|
+
if (o.status === 'approved') labels.set(String(o.task_id), { label: 'accept', source: 'override_approved' });
|
|
130
|
+
else if (o.status === 'denied' && !labels.has(String(o.task_id))) labels.set(String(o.task_id), { label: 'reject', source: 'override_denied' });
|
|
131
|
+
}
|
|
132
|
+
for (const g of grades || []) {
|
|
133
|
+
const id = String(g.task_id);
|
|
134
|
+
if (labels.has(id)) continue;
|
|
135
|
+
const landed = g.task_status === 'confirmed' || g.task_status === 'shipped';
|
|
136
|
+
if (landed && g.passed === false) labels.set(id, { label: 'accept', source: 'manual_confirm_after_fail' });
|
|
137
|
+
}
|
|
138
|
+
return labels;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function classify(kappa) {
|
|
142
|
+
if (kappa == null) return 'undetermined';
|
|
143
|
+
if (kappa >= REDUNDANT_KAPPA) return 'redundant';
|
|
144
|
+
if (kappa >= INDEPENDENT_KAPPA) return 'overlapping';
|
|
145
|
+
return 'independent';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// One stored grade → the slim row the audit reads (review prose hashed, as in
|
|
149
|
+
// the replay corpus): { task_id, task_status, passed, workers }, or null when
|
|
150
|
+
// it is not a panel grade.
|
|
151
|
+
function toAuditRow({ task_id, task_status, grade }) {
|
|
152
|
+
const row = trimRecordedGrade({ task_id, grade });
|
|
153
|
+
return row ? { task_id: row.task_id, task_status: task_status || null, passed: !!(grade && grade.passed === true), workers: row.workers } : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The whole report, pure over a dataset { grades: [toAuditRow…], overrides: [{task_id, status}] }.
|
|
157
|
+
function audit(dataset, { minN = MIN_N } = {}) {
|
|
158
|
+
const floor = Math.max(MIN_N, Number(minN) || MIN_N);
|
|
159
|
+
const panels = (dataset.grades || [])
|
|
160
|
+
.map((g) => ({ task_id: String(g.task_id), votes: panelVotes(g.workers) }))
|
|
161
|
+
.filter((p) => Object.keys(p.votes).length > 0);
|
|
162
|
+
const labels = ownerLabels(dataset);
|
|
163
|
+
// 1 when the worker's vote disagrees with the owner (objected to accepted
|
|
164
|
+
// work, or cleared rejected work); only on labelled tasks it voted on.
|
|
165
|
+
const errorOf = (p, kind) => (labels.has(p.task_id) && kind in p.votes
|
|
166
|
+
? Number(p.votes[kind] !== (labels.get(p.task_id).label === 'reject' ? 1 : 0))
|
|
167
|
+
: null);
|
|
168
|
+
|
|
169
|
+
const pairwise = [];
|
|
170
|
+
for (let i = 0; i < PANEL.length; i++) {
|
|
171
|
+
for (let j = i + 1; j < PANEL.length; j++) {
|
|
172
|
+
const a = PANEL[i]; const b = PANEL[j];
|
|
173
|
+
const pairs = panels.filter((p) => a in p.votes && b in p.votes).map((p) => [p.votes[a], p.votes[b]]);
|
|
174
|
+
const stats = guardedStats(pairs, floor);
|
|
175
|
+
// Error correlation: do the two get the SAME tasks wrong against the owner?
|
|
176
|
+
const errPairs = panels.map((p) => [errorOf(p, a), errorOf(p, b)]).filter(([x, y]) => x != null && y != null);
|
|
177
|
+
pairwise.push({ a, b, ...stats, reading: stats.refused ? 'undetermined' : classify(stats.kappa), errors: guardedStats(errPairs, floor) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// The panel's effective vote count is a claim about ALL of it, so it needs
|
|
181
|
+
// every pair — one computable pair extrapolated to four workers would be the
|
|
182
|
+
// over-reading this guard exists to stop.
|
|
183
|
+
const phis = pairwise.filter((p) => !p.refused && p.phi != null).map((p) => p.phi);
|
|
184
|
+
const meanPhi = phis.length === pairwise.length ? phis.reduce((s, x) => s + x, 0) / phis.length : null;
|
|
185
|
+
|
|
186
|
+
const byLabel = { accept: 0, reject: 0 };
|
|
187
|
+
for (const v of labels.values()) byLabel[v.label]++;
|
|
188
|
+
const perWorker = PANEL.map((kind) => {
|
|
189
|
+
const pairs = panels
|
|
190
|
+
.filter((p) => labels.has(p.task_id) && kind in p.votes)
|
|
191
|
+
.map((p) => [p.votes[kind], labels.get(p.task_id).label === 'reject' ? 1 : 0]);
|
|
192
|
+
return { kind, ...guardedStats(pairs, floor) };
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
min_n: floor,
|
|
197
|
+
panels: panels.length,
|
|
198
|
+
pairwise,
|
|
199
|
+
mean_pairwise_phi: meanPhi,
|
|
200
|
+
effective_votes: meanPhi == null ? null : effectiveVotes(PANEL.length, meanPhi),
|
|
201
|
+
labels: { total: labels.size, ...byLabel },
|
|
202
|
+
vs_owner: perWorker,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- the CLI ----------
|
|
207
|
+
|
|
208
|
+
async function fetchDataset(recent) {
|
|
209
|
+
const client = await cliClient();
|
|
210
|
+
const grades = (await fetchRecentGrades({ recent, client })).map(toAuditRow).filter(Boolean);
|
|
211
|
+
const overrides = [];
|
|
212
|
+
for (const status of ['approved', 'denied']) {
|
|
213
|
+
const r = await client.overrideRequests.getOverrideRequests({ query: { status } });
|
|
214
|
+
if (!r.ok) throw new Error(`GET /override-requests?status=${status} → HTTP ${r.status} (a Metic+ read)`);
|
|
215
|
+
for (const o of (r.data && r.data.requests) || []) overrides.push({ task_id: String(o.task_id), status: o.status, decided_at: o.decided_at || null });
|
|
216
|
+
}
|
|
217
|
+
return { pulled_at: new Date().toISOString(), grades, overrides };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
221
|
+
const num = (x) => (x == null ? 'n/a' : x.toFixed(2));
|
|
222
|
+
const statLine = (s) => (s.refused ? `REFUSED — ${s.refused}` : `n=${s.n} agree ${pct(s.agreement)} kappa ${num(s.kappa)} phi ${num(s.phi)}`);
|
|
223
|
+
|
|
224
|
+
function printReport(r) {
|
|
225
|
+
const out = [];
|
|
226
|
+
out.push(`grade correlation audit — ${r.panels} recorded panel grade(s), minimum n = ${r.min_n}`);
|
|
227
|
+
out.push('A vote is OBJECTS (concerns/fail) or CLEARS (pass); an errored worker with no finding casts none.');
|
|
228
|
+
out.push('');
|
|
229
|
+
out.push('(a) Do the workers fail differently? — agreement between each pair on the same task');
|
|
230
|
+
for (const p of r.pairwise) {
|
|
231
|
+
out.push(` ${`${p.a} × ${p.b}`.padEnd(22)} ${statLine(p)}${p.refused ? '' : ` → ${p.reading}`}`);
|
|
232
|
+
out.push(` ${''.padEnd(22)} same tasks wrong vs the owner: ${statLine(p.errors)}`);
|
|
233
|
+
}
|
|
234
|
+
const cleared = r.pairwise.filter((p) => !p.refused).length;
|
|
235
|
+
out.push(r.effective_votes == null
|
|
236
|
+
? ` effective independent votes: not computable — needs all ${r.pairwise.length} pairs, ${cleared} cleared the minimum`
|
|
237
|
+
: ` mean pairwise phi ${num(r.mean_pairwise_phi)} → about ${r.effective_votes.toFixed(1)} effective independent vote(s) out of 4`);
|
|
238
|
+
out.push('');
|
|
239
|
+
out.push(`(b) Each worker against the owner's decisions — ${r.labels.total} labelled task(s): ${r.labels.accept} accepted, ${r.labels.reject} rejected`);
|
|
240
|
+
for (const w of r.vs_owner) out.push(` ${w.kind.padEnd(22)} ${statLine(w)}`);
|
|
241
|
+
out.push('');
|
|
242
|
+
const redundant = r.pairwise.filter((p) => p.reading === 'redundant').map((p) => `${p.a}+${p.b}`);
|
|
243
|
+
const independent = r.pairwise.filter((p) => p.reading === 'independent').map((p) => `${p.a}+${p.b}`);
|
|
244
|
+
out.push('(c) Reading');
|
|
245
|
+
out.push(` redundant pairs (kappa >= 0.8): ${redundant.join(', ') || 'none'}`);
|
|
246
|
+
out.push(` independent pairs (kappa < 0.4): ${independent.join(', ') || 'none'}`);
|
|
247
|
+
out.push('');
|
|
248
|
+
out.push('Caveats: labels exist only where a person decided — override requests and manual confirms of work the');
|
|
249
|
+
out.push('panel did not pass — so they are a selected set, not a sample. A grade that auto-landed on a pass is');
|
|
250
|
+
out.push('never a label (that is the grader agreeing with itself). Only each task\'s LATEST grade keeps per-worker');
|
|
251
|
+
out.push('verdicts (grade_attempts stores pass/score only), so a re-graded task counts once, as its final round.');
|
|
252
|
+
console.log(out.join('\n'));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function main() {
|
|
256
|
+
const file = arg('--file');
|
|
257
|
+
const dataset = file
|
|
258
|
+
? JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
259
|
+
: await fetchDataset(Number(arg('--recent')) || 400);
|
|
260
|
+
const save = arg('--save');
|
|
261
|
+
if (save && !file) fs.writeFileSync(save, `${JSON.stringify(dataset)}\n`);
|
|
262
|
+
const report = audit(dataset, { minN: Number(arg('--min-n')) || MIN_N });
|
|
263
|
+
if (hasFlag('--json')) console.log(JSON.stringify(report, null, 2));
|
|
264
|
+
else printReport(report);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
MIN_N,
|
|
269
|
+
agreementStats,
|
|
270
|
+
audit,
|
|
271
|
+
effectiveVotes,
|
|
272
|
+
ownerLabels,
|
|
273
|
+
panelVotes,
|
|
274
|
+
toAuditRow,
|
|
275
|
+
voteOf,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (require.main === module) {
|
|
279
|
+
main().catch((err) => { console.error(`grade-correlation-audit: ${err.message}`); process.exit(2); });
|
|
280
|
+
}
|