@bongos/core 1.19.1032 → 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.
Files changed (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -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
 
@@ -57,6 +57,10 @@ function finalizeVerdict({ verdict, scores, issues, extra = {}, notesPrefix, not
57
57
  };
58
58
  }
59
59
 
60
+ // The marker the policy below stamps on a worker it downgraded. Exported so every
61
+ // reader (aggregatePanel, the ship card) names the one string instead of retyping it.
62
+ const UNBACKED_SCORE_DOWNGRADE = 'subthreshold_score_without_substantive_finding';
63
+
60
64
  // #1002663 severity-gate policy: style findings are advisory, and a
61
65
  // sub-threshold score must be BACKED by a substantive finding. Every worker
62
66
  // prompt already demands "a score below threshold must be accompanied by a
@@ -81,9 +85,35 @@ function applySeverityGatePolicy(result, { threshold, substantiveFail = false })
81
85
  return {
82
86
  ...result,
83
87
  verdict: 'concerns',
84
- policy_downgrade: 'subthreshold_score_without_substantive_finding',
88
+ policy_downgrade: UNBACKED_SCORE_DOWNGRADE,
85
89
  notes_md: `${result.notes_md}\n[severity-gate] sub-threshold score with no major/blocker finding — fail downgraded to concerns (style findings are advisory)`.trim(),
86
90
  };
87
91
  }
88
92
 
89
- module.exports = { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy };
93
+ // task 1002903 — the UNBACKED low scores in a panel: every sub-threshold axis of a
94
+ // worker the policy above downgraded, as [{ kind, axis, score }] in worker order.
95
+ //
96
+ // WHY THIS EXISTS. A downgraded worker still carries its low score, and that score
97
+ // still reaches the record (the axis median, the blended score) and the server's
98
+ // ff/cq gate. So the grade reads "FAIL 5.5" with no finding behind it, and the card
99
+ // told the builder to "fix the issues above" when there were none — the shape task
100
+ // 1002721 was filed as. Naming these lets the record and the card say what they
101
+ // are: a low mark nobody could point at, not a rejection with work to do.
102
+ //
103
+ // Keyed on the policy's own marker, so it can only ever list what the policy
104
+ // already decided: a low score that IS backed (the worker stays 'fail'), an errored
105
+ // worker, and the exempt Hacker (never downgraded) cannot appear. Pure; tolerates
106
+ // any input shape because signals.workers comes back off the wire.
107
+ function unbackedLowScores(workerResults, { threshold }) {
108
+ const out = [];
109
+ for (const w of (Array.isArray(workerResults) ? workerResults : [])) {
110
+ if (!w || typeof w !== 'object' || w.policy_downgrade !== UNBACKED_SCORE_DOWNGRADE) continue;
111
+ const scores = w.scores && typeof w.scores === 'object' ? w.scores : {};
112
+ for (const [axis, score] of Object.entries(scores)) {
113
+ if (Number.isFinite(score) && score < threshold) out.push({ kind: String(w.kind || '?'), axis, score });
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ module.exports = { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy, unbackedLowScores, UNBACKED_SCORE_DOWNGRADE };