@bongos/core 1.19.1033 → 1.19.1035

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.
Files changed (114) hide show
  1. package/.bongos-core.json +204 -99
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/clients/bongos-client/README.md +1 -1
  7. package/clients/bongos-client/bongos-client.global.js +6 -0
  8. package/clients/bongos-client/index.cjs +6 -0
  9. package/clients/bongos-client/index.d.ts +8 -0
  10. package/clients/bongos-client/index.mjs +6 -0
  11. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  12. package/docs/adr/README.md +1 -1
  13. package/docs/api/openapi.json +120 -4
  14. package/docs/api-reference.md +9 -2
  15. package/docs/architecture.md +3 -2
  16. package/docs/canonical-permissions.md +2 -2
  17. package/docs/copy-inventory.md +34 -30
  18. package/docs/copy-registry.json +69 -33
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/recipes/local-dev.md +45 -3
  21. package/docs/recipes/packaging-the-core.md +1 -1
  22. package/modules/autonomy/cadence.js +52 -0
  23. package/modules/autonomy/db.js +19 -0
  24. package/modules/autonomy/routes/autonomy.js +28 -0
  25. package/modules/government/catalog.js +2 -2
  26. package/modules/government/protected-surfaces.json +1 -0
  27. package/modules/grading/grade-engine.js +267 -0
  28. package/modules/grading/grader-subagent.js +4 -3
  29. package/modules/grading/grader-workers/index.js +67 -11
  30. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  31. package/modules/grading/grader.js +109 -88
  32. package/modules/hall-ui/public/deploy.js +35 -0
  33. package/modules/hall-ui/public/project-settings.css +7 -0
  34. package/modules/lifecycle/routes/tasks.js +19 -4
  35. package/modules/lifecycle/ship-assurance.js +17 -1
  36. package/modules/provisioning/control-plane-guard.js +14 -13
  37. package/modules/provisioning/core-upgrade.js +30 -17
  38. package/modules/provisioning/public-refusal.js +44 -0
  39. package/modules/provisioning/routes/disconnect.js +2 -1
  40. package/modules/provisioning/routes/env-manifest.js +5 -4
  41. package/modules/provisioning/routes/provisioning.js +16 -15
  42. package/modules/provisioning/routes/repo-private.js +2 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +80 -0
  46. package/scripts/gds/autobongos-run.js +10 -2
  47. package/scripts/gds/backfill-cost.js +20 -9
  48. package/scripts/gds/claim.js +2 -2
  49. package/scripts/gds/credential-presence.js +281 -0
  50. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  51. package/scripts/gds/dev-lib.js +212 -5
  52. package/scripts/gds/dev.js +102 -50
  53. package/scripts/gds/doctor.js +3 -2
  54. package/scripts/gds/grade-correlation-audit.js +280 -0
  55. package/scripts/gds/grade-replay.js +237 -0
  56. package/scripts/gds/hooks/pre-push.js +7 -3
  57. package/scripts/gds/main-audit.js +19 -10
  58. package/scripts/gds/memory-map.js +3 -1
  59. package/scripts/gds/onboarding-config.js +3 -2
  60. package/scripts/gds/plain-cards.js +4 -0
  61. package/scripts/gds/preflight.js +12 -4
  62. package/scripts/gds/provision-core-upgrade.js +155 -4
  63. package/scripts/gds/provision-repo-auth.js +15 -0
  64. package/scripts/gds/provision.js +1 -1
  65. package/scripts/gds/publish-credential-check.js +3 -0
  66. package/scripts/gds/push-main.js +14 -3
  67. package/scripts/gds/push-path-brief.js +3 -2
  68. package/scripts/gds/run-routine.js +18 -5
  69. package/scripts/gds/run-unit-tests.js +14 -3
  70. package/scripts/gds/session-cost.js +12 -3
  71. package/scripts/gds/ship-deploy-target.js +11 -6
  72. package/scripts/gds/ship-grade-steps.js +33 -0
  73. package/scripts/gds/ship-land.js +157 -11
  74. package/scripts/gds/ship-merge.js +3 -2
  75. package/scripts/gds/ship-preflight-steps.js +30 -8
  76. package/scripts/gds/ship.js +1 -1
  77. package/scripts/gds/upgrade.js +3 -3
  78. package/scripts/hall-preview/README.md +20 -3
  79. package/scripts/hall-preview/live-identity.js +1 -1
  80. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  81. package/scripts/hall-preview/scrub-identity.js +296 -0
  82. package/src/bongos/core-update.js +23 -1
  83. package/src/bongos/routes/core-update.js +27 -2
  84. package/src/bongos/routes/security.js +3 -3
  85. package/src/build-info.js +4 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/adr_0089_scope.mjs +70 -0
  88. package/tests/autobongos_cadence.mjs +74 -0
  89. package/tests/consumer_layout_boot.mjs +363 -31
  90. package/tests/core_upgrade_door.mjs +40 -6
  91. package/tests/core_upgrade_runner.mjs +240 -4
  92. package/tests/credential_presence.mjs +153 -0
  93. package/tests/deploy_audit_gate.mjs +5 -2
  94. package/tests/deploy_page_projects.mjs +63 -2
  95. package/tests/dev_lib.mjs +2 -1
  96. package/tests/dev_windows.mjs +324 -0
  97. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  98. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  99. package/tests/government_protected_surfaces.mjs +10 -0
  100. package/tests/grade_correlation_audit.mjs +159 -0
  101. package/tests/grade_engine.mjs +249 -0
  102. package/tests/grade_engine_replay.mjs +99 -0
  103. package/tests/grader_prompt_window_bound.mjs +132 -0
  104. package/tests/grader_unbacked_score.mjs +194 -0
  105. package/tests/hall_preview_scrub.mjs +214 -0
  106. package/tests/integration_parked_ratchet.mjs +24 -1
  107. package/tests/provisioning_public_refusal.mjs +195 -0
  108. package/tests/publish_status_branch.mjs +128 -0
  109. package/tests/routine_observability_api.mjs +195 -0
  110. package/tests/ship_assurance.mjs +86 -3
  111. package/tests/ship_land_red_pr.mjs +236 -0
  112. package/tests/ship_preflight.mjs +67 -1
  113. package/tests/start_env_guard.mjs +16 -0
  114. package/tests/upgrade.mjs +20 -1
@@ -46,7 +46,7 @@ If the no-leak gate fails, it names the file + the residual literal. Fix the red
46
46
 
47
47
  Two standing gates keep "a published core boots clean from a bare install" honest (task [#1002221](https://example.com/builders#/task/1002221), audit H1/H2):
48
48
 
49
- - **`node scripts/gds/run-unit-tests.js --integration`** — the integration-smoke gate. Runs the heavier integration set (longer per-test budget), including [`tests/consumer_layout_boot.mjs`](../../tests/consumer_layout_boot.mjs), which packages the core, `npm install`s the `.tgz`, and boots `/healthz` from the real consumer layout. Run it per release / on a schedule in an env that provides the services (network, and — for the deeper DB leg tracked as a follow-up — Postgres). The default `run-unit-tests.js` (no flag) stays the fast DB-free unit gate and *excludes* this set.
49
+ - **`node scripts/gds/run-unit-tests.js --integration`** — the integration-smoke gate. Runs the heavier integration set (longer per-test budget), including [`tests/consumer_layout_boot.mjs`](../../tests/consumer_layout_boot.mjs), which packages the core, `npm install`s the `.tgz`, and boots `/healthz` from the real consumer layout. Since task 1002863 it also carries the database half: it installs the unpacked package from its **own shipped lockfile** with `npm ci`, runs `npm run migrate` from the consumer onto a brand-new **empty** database (and onto a second one with every optional module switched on), then boots the installed server against the first and reads a sentinel row back through `GET /api/bongos/public/versions`. It creates and drops those databases itself, so it needs a Postgres whose role may `CREATE DATABASE`; without one it prints SKIP lines, except under `CI=true`, where a missing Postgres fails the run. The `integration` job of [`.github/workflows/integration.yml`](../../.github/workflows/integration.yml) runs this set on every push to `main` (not per PR — a cost decision recorded in that file). The default `run-unit-tests.js` (no flag) stays the fast DB-free unit gate and *excludes* this set.
50
50
  - **[`tests/pruned_lockfile_integrity.mjs`](../../tests/pruned_lockfile_integrity.mjs)** — a network-free unit guard (runs in the default gate every PR) that rebuilds the shipped pruned lockfile and asserts every registry package keeps a **well-formed, verbatim `integrity` hash**. This is the exact R88 regression (redaction once blanked those base64 hashes → `npm ci` could no longer verify) caught before a consumer's install ever fails.
51
51
 
52
52
  ## Consume + pin it (the R85 side)
@@ -50,6 +50,30 @@ const CLASSES = ['progress', 'quiet', 'stopped', 'transient', 'systemic', 'unwor
50
50
  // Only these advance the failure count toward `probing`.
51
51
  const COUNTS_AS_FAILURE = new Set(['transient', 'systemic']);
52
52
 
53
+ // WORKER_RAN_FLOOR_S — below this, a worker did not do any work (task 1004179).
54
+ //
55
+ // A real Claude Code session that read a brief, touched a repo and produced a
56
+ // verdict does not finish in seconds. The observed broken shape was 2s with no
57
+ // cost recorded at all. The floor is generous on purpose: it only has to
58
+ // separate "the process died on startup" from "a session ran", and being wrong
59
+ // in the permissive direction costs one extra iteration, while being wrong in
60
+ // the strict direction would call a fast legitimate run breakage.
61
+ const WORKER_RAN_FLOOR_S = 30;
62
+
63
+ // workerNeverRan — the supervisor already decided the worker FAILED; this asks
64
+ // whether it ever started. Both conditions are required: a worker that burned
65
+ // real time or real tokens and then failed DID run and met something, which is
66
+ // a different fact that this must not swallow.
67
+ function workerNeverRan(row = {}) {
68
+ if (row.outcome !== 'worker_failed') return false;
69
+ if (row.spawn_error) return true; // it could not even be launched
70
+ const cost = Number(row.cost_usd);
71
+ const spentNothing = !Number.isFinite(cost) || cost <= 0;
72
+ const dur = Number(row.duration_s);
73
+ const tooFast = Number.isFinite(dur) && dur < WORKER_RAN_FLOOR_S;
74
+ return spentNothing && tooFast;
75
+ }
76
+
53
77
  // SYSTEMIC_MARKERS — substrings that mean waiting will not help.
54
78
  //
55
79
  // A heuristic over an error string, which is a thing to be uneasy about, so the
@@ -86,6 +110,33 @@ function classifyEvent(row = {}) {
86
110
  // counting it as such would hold a broken runner at full speed indefinitely.
87
111
  if (row.verified === true) return { class: 'progress', detail: 'a task shipped and the ledger confirms it' };
88
112
  if (row.ledger_shape === 'undetermined') return { class: 'transient', detail: 'the ledger could not be read back' };
113
+ // DID THE WORKER ACTUALLY RUN? (task 1004179, found by the 1003907 proof run.)
114
+ //
115
+ // Everything below this line assumes a worker that ran and met a real
116
+ // obstacle — a grader refusal, a red main, a task it could not finish. That
117
+ // is genuinely quiet: the machine is fine, a blocker was filed, the task is
118
+ // out of the pool, and moving to the next one is right.
119
+ //
120
+ // A worker that never got going is the opposite, and it was landing in the
121
+ // same bucket. On the Windows host every worker exited in ~2s with "worker
122
+ // output was not JSON" because its argv was corrupted; each run filed a
123
+ // blocker, parked a task, and was classed `quiet` — which neither trips nor
124
+ // forgives the failure count. consecutive_failures stayed 0, the mode stayed
125
+ // `full`, and the runner would have worked through the entire allowlisted
126
+ // goal at one task per five minutes, parking every one of them, while the
127
+ // hall showed it alive and allowed to work. It got through one task before a
128
+ // human stopped it.
129
+ //
130
+ // The queue-vs-runner distinction this module already draws is the same one:
131
+ // `unworkable_queue` is a healthy machine with nothing it can do, and must
132
+ // never escalate. A worker that cannot start is a BROKEN machine with plenty
133
+ // it should be doing, and must.
134
+ if (workerNeverRan(row)) {
135
+ return {
136
+ class: looksSystemic(reason) ? 'systemic' : 'transient',
137
+ detail: `the worker never ran (${row.outcome || 'worker_failed'}${Number.isFinite(Number(row.duration_s)) ? `, ${row.duration_s}s` : ''}, no cost recorded): ${String(row.reason || '').slice(0, 200)}`,
138
+ };
139
+ }
89
140
  // It ran, it did not ship. Not breakage — a blocker was filed and the task is
90
141
  // out of the pool — but not progress either. Quiet keeps the runner at full
91
142
  // speed so it moves on to the next task, which is the right response.
@@ -273,4 +324,5 @@ function shouldJumpWait(before, after) {
273
324
  module.exports = {
274
325
  CLASSES, COUNTS_AS_FAILURE, SYSTEMIC_MARKERS, DEFAULTS, START,
275
326
  looksSystemic, classifyEvent, nextCadence, signalFingerprint, shouldJumpWait,
327
+ workerNeverRan, WORKER_RAN_FLOOR_S,
276
328
  };
@@ -44,6 +44,24 @@ async function lastAutonomyHead(routine) {
44
44
  return rows[0]?.head_sha || null;
45
45
  }
46
46
 
47
+ // The recent autonomy-gate decisions, newest first — the off-box answer to "did the
48
+ // routine fire, and what did it decide" (task 1002669). Every precheck writes a row
49
+ // here, every SKIP included, so the table is the whole record; before this read
50
+ // existed, finding out needed an SSH session on the host (blocker 1000106). `routine`
51
+ // null means every routine. The (routine, created_at DESC) index serves both shapes.
52
+ async function readAutonomyRuns({ routine = null, limit = 20 } = {}) {
53
+ const { rows } = await pool.query(
54
+ `SELECT id, routine, decision, reason, head_sha, claimable_count, enabled, created_at
55
+ FROM autonomy_runs
56
+ WHERE ($1::text IS NULL OR routine = $1)
57
+ ORDER BY created_at DESC, id DESC
58
+ LIMIT $2`,
59
+ [routine, limit]
60
+ );
61
+ // bigserial ids arrive as strings from node-postgres; keep them strings on purpose.
62
+ return rows.map((r) => ({ ...r, id: String(r.id) }));
63
+ }
64
+
47
65
  // ── the Autobongos fence (task 1003908, ADR 0307 §6) ─────────────────────────
48
66
  //
49
67
  // These back the one control surface the runner host cannot write to. Everything
@@ -162,6 +180,7 @@ async function readRunnerHeartbeats(builderId) {
162
180
  module.exports = {
163
181
  recordAutonomyRun,
164
182
  lastAutonomyHead,
183
+ readAutonomyRuns,
165
184
  recordRunnerHeartbeat,
166
185
  readRunnerHeartbeats,
167
186
  readFence,
@@ -42,6 +42,16 @@ const { validateOrRespond } = api;
42
42
  //
43
43
  // rank: archon — autonomy is an ops control surface, and a precheck records an
44
44
  // autonomy_runs row + git-fetches. Keep it archon-scoped (ADR 0016).
45
+ // ?limit for GET /autonomy/runs: an integer 1..100, else the default 20. Clamped rather
46
+ // than refused — a read an operator types by hand should answer, not argue.
47
+ const RUNS_DEFAULT_LIMIT = 20;
48
+ const RUNS_MAX_LIMIT = 100;
49
+ function runsLimit(raw) {
50
+ const n = Number.parseInt(String(raw ?? ''), 10);
51
+ if (!Number.isFinite(n)) return RUNS_DEFAULT_LIMIT;
52
+ return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
53
+ }
54
+
45
55
  module.exports = function buildAutonomyRouter() {
46
56
  const router = express.Router();
47
57
 
@@ -58,6 +68,24 @@ module.exports = function buildAutonomyRouter() {
58
68
  }
59
69
  });
60
70
 
71
+ // The gate's past decisions — did a routine fire, and what did it decide, and why
72
+ // (task 1002669). READ-ONLY and behind the SAME atom as the precheck above
73
+ // (`autonomy.precheck`, Metic+): whoever may ask the gate the question may read its
74
+ // past answers, and a new atom would need a migration for no change in audience.
75
+ // Adds no gate anywhere — pure observability over rows the precheck already writes.
76
+ // ?routine=<name> narrows to one routine; ?limit=1..100 (default 20), newest first.
77
+ router.get('/autonomy/runs', auth.requireBuilder, auth.requirePermission('autonomy.precheck'), async (req, res) => {
78
+ const routine = typeof req.query.routine === 'string' && req.query.routine ? req.query.routine.slice(0, 64) : null;
79
+ const limit = runsLimit(req.query.limit);
80
+ try {
81
+ const runs = await db.readAutonomyRuns({ routine, limit });
82
+ res.json({ routine, limit, runs });
83
+ } catch (err) {
84
+ log.error('[gds] GET /autonomy/runs', err);
85
+ res.fail('autonomy_runs_unreadable', { status: 503, message: 'could not read the autonomy decisions' });
86
+ }
87
+ });
88
+
61
89
  // ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
62
90
  //
63
91
  // These four routes ARE the fence. Everything the unattended runner is allowed
@@ -108,7 +108,7 @@ const PERMISSIONS = [
108
108
  // owner holds: the catalog's own invariant forbids granting a system key to metic,
109
109
  // so this cannot be delegated downward by a custom rank. The web tier still gains
110
110
  // no shell - the route only QUEUES an intent the control-plane runner drains.
111
- { key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293)' },
111
+ { key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293); also the reads GET /core-update (the upgrade banner) and GET /core-upgrades (the upgrade ledger, task 1002669)' },
112
112
  // autonomy.fence.manage (task 1003908, ADR 0307 §6) — THE AUTOBONGOS KILL
113
113
  // SWITCH and the allowlist of goals an unattended runner may work. system:true
114
114
  // + archon for core.pin.move's reason, in a slightly sharper form: this decides
@@ -253,7 +253,7 @@ const PERMISSIONS = [
253
253
  { key: 'security.report.adjudicate', system: false, floor: 'metic', guards: 'GET /security/reports, POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD (ADR 0157, was archon)' },
254
254
  { key: 'provisioning.fleet.manage', system: false, floor: 'metic', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown (ADR 0157, was archon)' },
255
255
  { key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', verb: 'manage', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
256
- { key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon)' },
256
+ { key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon); also GET /autonomy/runs, the gate\'s past decisions (task 1002669)' },
257
257
  { key: 'discord.channels.manage', system: false, floor: 'metic', guards: 'GET /discord/channels/plan, POST .../reconcile (ADR 0157, was archon)' },
258
258
  // Metic by the discord.channels.manage analogue — configuring a project-wide
259
259
  // social surface — rather than archon. The blast radius is bounded by the OTHER
@@ -59,6 +59,7 @@
59
59
  { "glob": "modules/grading/grader-prompt.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
60
60
  { "glob": "modules/grading/grader-score.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
61
61
  { "glob": "modules/grading/grader-subagent.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
62
+ { "glob": "modules/grading/grade-engine.js", "group": "pipeline", "why": "task 1002839. The central grading engine every work-type is graded through: it runs the deterministic route-rank and permission-path pre-passes and applies the deterministic veto, so editing it can switch off the protected-path gate for every grade at once." },
62
63
  { "glob": "modules/grading/grader-workers/", "group": "pipeline", "why": "the adversarial panel (Narc/Hacker/Quality/Efficiency) — blunting these blunts the gate" },
63
64
  { "glob": "modules/grading/grader-rubric.json", "group": "pipeline", "why": "the grader's scoring rubric (ADR 0058)" },
64
65
  { "glob": "scripts/gds/ship.js", "group": "pipeline", "why": "the ship lifecycle driver" },
@@ -0,0 +1,267 @@
1
+ // modules/grading/grade-engine.js — the central grading engine: ONE named
2
+ // pipeline every work-type is graded through, and the registry a work-type's
3
+ // grader registers into (ADR 0089 §1, task 1002839).
4
+ //
5
+ // WHY. ADR 0089 decided the grader is a central, work-type-parameterized engine:
6
+ // the orchestration, the deterministic security pre-passes, the gate and the
7
+ // persisted record are written once, and a work-type contributes only what is
8
+ // genuinely its own — its judges, the evidence they read, and how their verdicts
9
+ // combine. Until this file that pipeline existed only as the body of
10
+ // gradeSubagent, hard-wired to the code panel, so a second work-type had no way
11
+ // in. The stages below are the same ones, in the same order, now named.
12
+ //
13
+ // THE PIPELINE (PIPELINE_STAGES, in order):
14
+ // select resolve the work-type: an explicit key, else task.discipline, else
15
+ // task.kind, else the default (the code panel).
16
+ // pre_pass the deterministic route-rank + permission-path checks. ENGINE-OWNED
17
+ // and zero-LLM: a work-type cannot skip, reorder or soften them.
18
+ // evidence the work-type gathers what its judges read (code: the post-change
19
+ // file bundle + generated-file provenance, read once).
20
+ // fan_out every judge runs independently, in parallel.
21
+ // aggregate the work-type combines its judges' verdicts (code: aggregatePanel).
22
+ // gate ENGINE-OWNED deterministic veto: a pre-pass that hard-failed fails
23
+ // the grade whatever the aggregate said — no verdict, from any model,
24
+ // upgrades a fail over a protected path (ADR 0016 / 0043 / 0080). For
25
+ // the code panel it never fires, because aggregatePanel already fails
26
+ // on a deterministic fail; it is what makes the guarantee hold for a
27
+ // work-type whose aggregate does not.
28
+ // persist shape the record the lifecycle stores: per-judge signals, cost, and
29
+ // the run meta. Grading owns no table (ADR 0091 §4) — the write itself
30
+ // stays db.applyGrade behind POST /tasks/:id/grade, where
31
+ // serverSideGuards re-runs the same pre-passes on authoritative data.
32
+ //
33
+ // THE REGISTRY HAS TWO DOORS, because ADR 0083 forbids one module requiring
34
+ // another. The grading module's own work-types register directly
35
+ // (registerWorkType — the code panel does, from grader.js). Any other module
36
+ // contributes one as a provider on the kernel seam port `grader.workType.<key>`,
37
+ // through the doorway — the only door it has. resolveWorkType reads both, local
38
+ // first, so a seam reset (tests do one) can never lose the default.
39
+ //
40
+ // REPLAY. replayRecorded re-runs aggregate + gate over a grade's PERSISTED
41
+ // per-judge signals — zero model calls. It is how a change to either stage is
42
+ // proven against real recorded grades before it touches the live gate
43
+ // (scripts/gds/grade-replay.js, tests/grade_engine_replay.mjs).
44
+
45
+ 'use strict';
46
+
47
+ const api = require('../../src/module-api');
48
+ const { ASSUMED_BUILDER_MODEL, permissionPathPrePass, routeRankPrePass } = require('./grader-config.js');
49
+ const { selectGraderModel } = require('./grader-model.js');
50
+ const { extractAssistantResult, runSubagentCached } = require('./grader-subagent.js');
51
+
52
+ const PIPELINE_STAGES = Object.freeze(['select', 'pre_pass', 'evidence', 'fan_out', 'aggregate', 'gate', 'persist']);
53
+
54
+ const WORK_TYPE_PORT_PREFIX = 'grader.workType.';
55
+ const WORK_TYPE_KEY_RE = /^[a-z][a-z0-9_-]{0,39}$/;
56
+ // What a work-type must supply. gatherEvidence and recordEvidence are optional
57
+ // (a work-type whose judges read only the task + diff needs neither).
58
+ const REQUIRED_HOOKS = Object.freeze(['resolveJudges', 'modelFor', 'runJudge', 'aggregate', 'unavailable']);
59
+ // The pseudo-judge kinds the deterministic pre-passes produce. Replay rebuilds
60
+ // the pre-pass results from them so its gate judges what the live gate judged.
61
+ const DETERMINISTIC_KINDS = Object.freeze(['route-rank', 'permission-path']);
62
+
63
+ const local = new Map(); // key -> work-type registered directly by this module
64
+ let defaultKey = null;
65
+
66
+ function workTypePort(key) {
67
+ return `${WORK_TYPE_PORT_PREFIX}${key}`;
68
+ }
69
+
70
+ // Refuse a malformed work-type at REGISTRATION, not on the first ship that
71
+ // resolves it — a missing hook would otherwise surface as a TypeError mid-grade.
72
+ function validateWorkType(def) {
73
+ if (!def || typeof def !== 'object') throw new TypeError('grade engine: a work-type must be an object');
74
+ if (typeof def.key !== 'string' || !WORK_TYPE_KEY_RE.test(def.key)) {
75
+ throw new TypeError(`grade engine: work-type key ${JSON.stringify(def.key)} must match ${WORK_TYPE_KEY_RE}`);
76
+ }
77
+ const missing = REQUIRED_HOOKS.filter((h) => typeof def[h] !== 'function');
78
+ for (const h of ['gatherEvidence', 'recordEvidence']) {
79
+ if (def[h] != null && typeof def[h] !== 'function') missing.push(h);
80
+ }
81
+ if (missing.length) throw new TypeError(`grade engine: work-type '${def.key}' is missing hook(s): ${missing.join(', ')}`);
82
+ return def;
83
+ }
84
+
85
+ // Register one of THIS module's work-types. Idempotent for the same definition
86
+ // (grader.js is required from several places); a second, different definition
87
+ // for a key is a wiring bug and throws, like a duplicate seam provider.
88
+ function registerWorkType(def, { isDefault = false } = {}) {
89
+ validateWorkType(def);
90
+ const held = local.get(def.key);
91
+ if (held && held !== def) throw new Error(`grade engine: work-type '${def.key}' is already registered`);
92
+ local.set(def.key, def);
93
+ if (isDefault) defaultKey = def.key;
94
+ return def;
95
+ }
96
+
97
+ // A work-type contributed by another module through the seam, or null. An
98
+ // invalid contribution is refused loudly and treated as absent, so a broken
99
+ // module degrades to the default grader instead of crashing every grade.
100
+ function seamWorkType(key) {
101
+ const port = workTypePort(key);
102
+ if (!api.hasProvider(port)) return null;
103
+ const def = api.resolve(port);
104
+ try {
105
+ validateWorkType(def);
106
+ } catch (err) {
107
+ console.error(`grade engine: ignoring invalid work-type on port ${port}: ${err.message}`);
108
+ return null;
109
+ }
110
+ return def.key === key ? def : null;
111
+ }
112
+
113
+ function lookup(key) {
114
+ if (typeof key !== 'string' || !key) return null;
115
+ return local.get(key) || seamWorkType(key);
116
+ }
117
+
118
+ // select — explicit key, then the task's discipline, then its kind, then the
119
+ // default. Never throws once a default is registered: an unknown or mistyped
120
+ // key grades on the default rather than stranding the ship (the task 1002659
121
+ // posture — a grade step that throws strands the ship).
122
+ function resolveWorkType({ workType, task } = {}) {
123
+ if (workType && typeof workType === 'object') return validateWorkType(workType);
124
+ const t = task || {};
125
+ for (const key of [workType, t.discipline, t.kind]) {
126
+ const def = lookup(key);
127
+ if (def) return def;
128
+ }
129
+ const def = defaultKey ? local.get(defaultKey) : null;
130
+ if (!def) throw new Error('grade engine: no default work-type registered (modules/grading/grader.js registers the code panel)');
131
+ return def;
132
+ }
133
+
134
+ // Every work-type the engine can currently resolve, sorted — local and seam.
135
+ function listWorkTypes() {
136
+ const keys = new Set(local.keys());
137
+ for (const port of api.listPorts()) {
138
+ if (!port.startsWith(WORK_TYPE_PORT_PREFIX)) continue;
139
+ const key = port.slice(WORK_TYPE_PORT_PREFIX.length);
140
+ if (seamWorkType(key)) keys.add(key);
141
+ }
142
+ return [...keys].sort();
143
+ }
144
+
145
+ // pre_pass — both deterministic checks, in this order, for every work-type. Each
146
+ // is null (zero cost) when its trigger is absent; the rest of the pipeline sees
147
+ // only the ones that ran.
148
+ function runPrePasses({ changedFiles, builderRank }) {
149
+ return [routeRankPrePass(changedFiles), permissionPathPrePass(changedFiles, builderRank)].filter(Boolean);
150
+ }
151
+
152
+ // gate — the deterministic veto. Downgrade-only: it can turn a non-fail into a
153
+ // fail, never the reverse, and it returns the aggregate UNTOUCHED when there is
154
+ // nothing to enforce (always, for the code panel).
155
+ function applyDeterministicGate(result, prePasses) {
156
+ const failed = prePasses.filter((p) => p && p.workerResult && p.workerResult.verdict === 'fail');
157
+ if (!failed.length) return result;
158
+ const signals = (result && result.signals) || {};
159
+ if (result && result.passed !== true && signals.panel_outcome === 'fail') return result;
160
+ const kinds = failed.map((p) => p.workerResult.kind);
161
+ return {
162
+ ...result,
163
+ passed: false,
164
+ signals: { ...signals, panel_outcome: 'fail', confidence: 0, deterministic_veto: kinds },
165
+ notes_md: `${(result && result.notes_md) || ''}\n\nGrade engine — DETERMINISTIC VETO: ${kinds.join(', ')} hard-failed, so this grade FAILS whatever the judges said (ADR 0016 / 0043 / 0080: no verdict may upgrade a fail over a protected path).`,
166
+ };
167
+ }
168
+
169
+ // persist — append the run meta to the record's signals. Appended AFTER
170
+ // aggregate and gate, so the key order is exactly what aggregatePanel produced
171
+ // when it was handed the meta directly.
172
+ function persist(result, meta) {
173
+ return { ...result, signals: { ...(result.signals || {}), ...meta } };
174
+ }
175
+
176
+ // The model call every judge makes: runSubagent wrapped in the content-addressed
177
+ // LLM cache (System 5) — the same prompt + model replays at $0. Absent llm-cache,
178
+ // a plain run. `taskType` is part of the cache key, so a work-type keeps its own.
179
+ function cachedModelCall(taskType) {
180
+ return ({ prompt, opts }) => runSubagentCached({
181
+ prompt,
182
+ opts,
183
+ cache: api.llmCache
184
+ ? api.llmCache.makeCacheSpec({ prompt, model: opts?.model, task_type: taskType })
185
+ : null,
186
+ });
187
+ }
188
+
189
+ // The whole pipeline for one grade. `deps.runSubagent` replaces the model call
190
+ // (tests; production never passes it). Returns the work-type's grade-result.
191
+ async function runGrade(input = {}, deps = {}) {
192
+ const { task, valueSummary, diff, changedFiles, diffStats, judges, builderModel, builderRank, priorRound, runOpts = {} } = input;
193
+
194
+ // select
195
+ const wt = resolveWorkType({ workType: input.workType, task });
196
+
197
+ // #943: bypass / disabled short-circuit — BEFORE resolving judges or spawning
198
+ // anything. Spawning into an outage/bypass window is what manufactured the
199
+ // "FAIL · 0/10" false verdict in session 1659.
200
+ if (runOpts.bypass === true || process.env.GRADER_BYPASS_ENABLED === '1') {
201
+ const reason = runOpts.bypass === true ? 'bypass_requested' : 'grader_bypass_enabled';
202
+ return wt.unavailable({
203
+ reason,
204
+ note: 'Grader bypassed before worker spawn (GRADER_BYPASS_ENABLED / runOpts.bypass) — no panel ran. Land via manual-confirm (/confirm).',
205
+ meta: { builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL, bypassed: true },
206
+ });
207
+ }
208
+ const panel = wt.resolveJudges(judges);
209
+ const graderModel = selectGraderModel({ builderModel });
210
+
211
+ // pre_pass
212
+ const prePasses = runPrePasses({ changedFiles, builderRank });
213
+ const deterministicFindings = prePasses.flatMap((p) => p.findings);
214
+
215
+ // evidence
216
+ const ctx = { task, valueSummary, diff, changedFiles, diffStats, builderModel, builderRank, priorRound, runOpts, graderModel, deterministicFindings };
217
+ const evidence = wt.gatherEvidence ? await wt.gatherEvidence(ctx) : {};
218
+
219
+ // fan_out
220
+ const runSubagentFn = deps.runSubagent || cachedModelCall(wt.cacheTaskType || `grade_${wt.key}`);
221
+ const startedAt = Date.now();
222
+ const results = await Promise.all(panel.map((judge) => wt.runJudge(judge, {
223
+ ...ctx,
224
+ evidence,
225
+ model: wt.modelFor(judge, graderModel, builderModel),
226
+ runSubagentFn,
227
+ extractAssistantResultFn: extractAssistantResult,
228
+ })));
229
+ // The deterministic checks join as pseudo-judges, after the model judges, so
230
+ // an aggregate's any-fail rule sees them like any other verdict.
231
+ for (const p of prePasses) results.push(p.workerResult);
232
+ const totalMs = Date.now() - startedAt;
233
+
234
+ // aggregate → gate → persist
235
+ const gated = applyDeterministicGate(wt.aggregate(results, {}), prePasses);
236
+ return persist(gated, {
237
+ builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL,
238
+ grader_model: graderModel,
239
+ total_ms: totalMs,
240
+ ...(wt.recordEvidence ? wt.recordEvidence(evidence, ctx) : {}),
241
+ });
242
+ }
243
+
244
+ // Re-run aggregate + gate over a grade's persisted per-judge signals
245
+ // (signals.workers — the deterministic pseudo-judges included). Pure: no model
246
+ // call, no I/O. The pre-pass results are rebuilt from the recorded pseudo-judges,
247
+ // so the gate judges exactly what the live run's gate judged.
248
+ function replayRecorded({ signals, workType } = {}) {
249
+ const wt = resolveWorkType({ workType });
250
+ const judges = Array.isArray(signals && signals.workers) ? signals.workers : [];
251
+ const prePasses = judges
252
+ .filter((w) => w && w.model === 'deterministic' && DETERMINISTIC_KINDS.includes(w.kind))
253
+ .map((w) => ({ findings: w.issues || [], workerResult: w }));
254
+ return applyDeterministicGate(wt.aggregate(judges, {}), prePasses);
255
+ }
256
+
257
+ module.exports = {
258
+ PIPELINE_STAGES,
259
+ WORK_TYPE_PORT_PREFIX,
260
+ applyDeterministicGate,
261
+ listWorkTypes,
262
+ registerWorkType,
263
+ replayRecorded,
264
+ resolveWorkType,
265
+ runGrade,
266
+ workTypePort,
267
+ };
@@ -19,9 +19,10 @@ function runSubagent({ prompt, opts = {} }) {
19
19
  const timeoutMs = opts.timeoutMs ?? 240_000;
20
20
  const cwd = opts.cwd ?? process.cwd();
21
21
  const model = opts.model ?? DEFAULT_GRADER_MODEL;
22
- // Env escape hatch (OTB_CLAUDE_BIN / CLAUDE_BIN) for installs where the CLI
23
- // isn't named `claude` on PATH — covers any OS, not just Windows.
24
- const bin = opts.claudeBin ?? process.env.OTB_CLAUDE_BIN ?? process.env.CLAUDE_BIN ?? 'claude';
22
+ // Env escape hatch (<PREFIX>_CLAUDE_BIN — BONGOS_ on a vanilla instance, the
23
+ // legacy OTB_ still read — then the unprefixed CLAUDE_BIN) for installs where the
24
+ // CLI isn't named `claude` on PATH — covers any OS, not just Windows.
25
+ const bin = opts.claudeBin ?? api.resolveEnv('CLAUDE_BIN') ?? process.env.CLAUDE_BIN ?? 'claude';
25
26
 
26
27
  // THE PERMISSION MODE IS ROOT-DEPENDENT (task 1003574). `bypassPermissions` maps
27
28
  // to --dangerously-skip-permissions, and the CLI REFUSES that when euid is 0:
@@ -87,8 +87,22 @@ const TRANSIENT_WORKER_ERRORS = new Set([
87
87
  // errored no-finding Quality — the tag is the diagnosis, not the trigger).
88
88
  // The pattern is the API's exact sentence, so a diff that merely mentions the
89
89
  // phrase cannot trip it.
90
- const PROMPT_TOO_LONG_RE = /prompt is too long: the request is ~?\d+ tokens/i;
90
+ //
91
+ // task 1003648: the separator is NOT a colon. The line the CLI really prints —
92
+ // stored verbatim on task 1003298's grade row — is
93
+ // "Prompt is too long · the request is ~251248 tokens (limit 200000) ..."
94
+ // and the colon form above was a paraphrase, so the tag never fired on a real
95
+ // overflow: it was filed as SUBAGENT_NONZERO_EXIT and retried as if transient.
96
+ // Any short run of non-word characters between the halves matches both.
97
+ const PROMPT_TOO_LONG_RE = /prompt is too long\W{1,5}the request is ~?\d+ tokens/i;
91
98
  const PROMPT_TOO_LONG = 'PROMPT_TOO_LONG';
99
+ // The numbers in that same sentence, recorded so a bounded row says how far
100
+ // over the window it was. Absent from a line that does not carry them.
101
+ const OVERFLOW_NUMBERS_RE = /the request is ~?(\d+) tokens \(limit (\d+)\)/i;
102
+ function overflowNumbers(said) {
103
+ const m = OVERFLOW_NUMBERS_RE.exec(String(said || ''));
104
+ return m ? { request_tokens: Number(m[1]), window_tokens: Number(m[2]) } : {};
105
+ }
92
106
 
93
107
  // #467: per-worker subagent timeout. The global 240s applied to EVERY worker,
94
108
  // which timed out the Read-heavy ones (Narc walks ACs + reads files — hence
@@ -195,13 +209,14 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
195
209
  // builder response for a delta-aware re-grade; every worker renders it via
196
210
  // the shared scaffold. contextBundle (task 1002660) likewise: the post-change
197
211
  // file contents read once by the caller, rendered by the shared scaffold.
198
- const prompt = worker.buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, deterministicFindings, priorRound, contextBundle });
212
+ const promptInputs = { task, valueSummary, diff, changedFiles, diffStats, deterministicFindings, priorRound, contextBundle };
213
+ const prompt = worker.buildPrompt(promptInputs);
199
214
 
200
215
  // One subagent attempt → verdict. Pulled out so #465 can re-run it once on a
201
216
  // transient error without duplicating the spawn/parse/verdict plumbing.
202
217
  // #467: per-worker timeout (Read-heavy workers get longer; see the map above).
203
218
  const timeoutMs = timeoutForWorker(worker, runOpts);
204
- async function attempt() {
219
+ async function attempt(promptText) {
205
220
  let raw = null;
206
221
  let runMs = null;
207
222
  let costUsd = null;
@@ -209,9 +224,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
209
224
  // task 1003313: the WHY behind subprocessError. Null on a clean run.
210
225
  let exitCode = null;
211
226
  let stderrTail = null;
227
+ let overflow = null; // task 1003648: the refusal's own numbers, on a PROMPT_TOO_LONG only
212
228
  const t0 = Date.now();
213
229
  try {
214
- const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt, opts: { ...runOpts, model, timeoutMs } });
230
+ const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt: promptText, opts: { ...runOpts, model, timeoutMs } });
215
231
  runMs = run_ms;
216
232
  // #486 (ADR 0028 path 1): runSubagent attaches the `claude -p` envelope's
217
233
  // total_cost_usd. Carry it out so aggregatePanel can sum the panel's own
@@ -232,7 +248,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
232
248
  // the #467 budget signal, never a refusal.
233
249
  if (err.code !== 'SUBAGENT_TIMEOUT') {
234
250
  const said = String(err.stderr || '').trim() || String(err.stdout || '');
235
- if (PROMPT_TOO_LONG_RE.test(said)) subprocessError = PROMPT_TOO_LONG;
251
+ if (PROMPT_TOO_LONG_RE.test(said)) {
252
+ subprocessError = PROMPT_TOO_LONG;
253
+ overflow = overflowNumbers(said);
254
+ }
236
255
  }
237
256
  }
238
257
  const parsed = subprocessError
@@ -243,18 +262,47 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
243
262
  run_ms: runMs,
244
263
  subprocess_error: subprocessError,
245
264
  });
246
- return { verdict, runMs, costUsd, exitCode, stderrTail };
265
+ return { verdict, runMs, costUsd, exitCode, stderrTail, overflow };
247
266
  }
248
267
 
249
- let { verdict, runMs, costUsd, exitCode, stderrTail } = await attempt();
250
268
  // #465: retry ONCE on a transient error (the freshest attempt wins, even if it
251
269
  // also errored — one retry only). A real verdict or a SUBAGENT_TIMEOUT is
252
270
  // never retried. NB (#486): the freshest attempt's cost wins; a transient
253
271
  // first attempt's spend is not added (slight, rare undercount).
254
- let retried = false;
255
- if (TRANSIENT_WORKER_ERRORS.has(verdict.error)) {
256
- retried = true;
257
- ({ verdict, runMs, costUsd, exitCode, stderrTail } = await attempt());
272
+ async function attemptWithRetry(promptText) {
273
+ const first = await attempt(promptText);
274
+ if (!TRANSIENT_WORKER_ERRORS.has(first.verdict.error)) return { ...first, retried: false };
275
+ return { ...(await attempt(promptText)), retried: true };
276
+ }
277
+
278
+ let { verdict, runMs, costUsd, exitCode, stderrTail, overflow, retried } = await attemptWithRetry(prompt);
279
+
280
+ // task 1003648 — a prompt too big for this worker's model window degrades
281
+ // instead of failing outright. The CLI measures the real window and the real
282
+ // overhead (its own system prompt, tool definitions and auto-loaded context —
283
+ // ~150k tokens of task 1003298's 251k-token refusal), so the refusal is the
284
+ // authoritative "does not fit"; the one part of the prompt that can go without
285
+ // losing any evidence is the post-change file bundle, because the worker can
286
+ // Read those files itself. So: retry ONCE without it. The diff, the task and the
287
+ // prior round all stay — the retried worker reviews the same change, it just
288
+ // fetches the files instead of being handed them. If even that does not fit,
289
+ // the worker stays PROMPT_TOO_LONG: an explicit lens-unavailable, never a score.
290
+ // No bundle to drop → nothing smaller to try, so no second refusal is spent.
291
+ let promptBounded = null;
292
+ const bundledFiles = contextBundle && Array.isArray(contextBundle.files) ? contextBundle.files : [];
293
+ if (verdict.error === PROMPT_TOO_LONG && bundledFiles.length > 0) {
294
+ // Generated-file provenance is verified, trusted text — it stays.
295
+ const smaller = worker.buildPrompt({ ...promptInputs, contextBundle: { ...contextBundle, files: [] } });
296
+ promptBounded = {
297
+ reason: PROMPT_TOO_LONG,
298
+ bundle_dropped: true,
299
+ bundle_files: bundledFiles.length,
300
+ prompt_chars: prompt.length,
301
+ retry_prompt_chars: smaller.length,
302
+ ...(overflow || {}),
303
+ };
304
+ ({ verdict, runMs, costUsd, exitCode, stderrTail, overflow, retried } = await attemptWithRetry(smaller));
305
+ promptBounded.retry_error = verdict.error ?? null;
258
306
  }
259
307
 
260
308
  return {
@@ -277,6 +325,14 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
277
325
  ...(verdict.error && stderrTail ? { stderr_tail: stderrTail } : {}),
278
326
  // Narc-specific (carried opaquely; aggregatePanel doesn't need to know):
279
327
  ...(verdict.ac_checklist ? { ac_checklist: verdict.ac_checklist } : {}),
328
+ // task 1002903: the severity gate's marker. Rebuilding the row field by field
329
+ // used to drop it, so a stored grade could not say which low score was the
330
+ // one no finding backed — only the worker's prose did. Omitted when absent,
331
+ // so an ordinary worker row keeps its exact shape.
332
+ ...(verdict.policy_downgrade ? { policy_downgrade: verdict.policy_downgrade } : {}),
333
+ // task 1003648: present only when the first prompt overflowed the window and
334
+ // a smaller one was tried, so an ordinary row keeps its exact shape.
335
+ ...(promptBounded ? { prompt_bounded: promptBounded } : {}),
280
336
  };
281
337
  }
282
338