@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
@@ -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 };
@@ -46,7 +46,9 @@
46
46
  // (narc + quality + hacker + efficiency — the full 4-worker panel, so an
47
47
  // honest ship always clears hasPanelEvidence's ≥3-worker floor). Wraps
48
48
  // subprocess failures so resolveClaim/applyGrade can always write
49
- // task_grades.
49
+ // task_grades. Since task 1002839 it is the code panel (CODE_WORK_TYPE,
50
+ // below) run through the central grade engine (grade-engine.js), which
51
+ // owns the pipeline, the deterministic pre-passes and the veto.
50
52
  //
51
53
  // aggregatePanel(workerResults, meta)
52
54
  // → final grade-result from per-worker WorkerResults. Applies the
@@ -82,7 +84,7 @@ const path = require('node:path');
82
84
  const fs = require('node:fs');
83
85
  const { spawn } = require('node:child_process');
84
86
 
85
- const { ASSUMED_BUILDER_MODEL, DEFAULT_GRADER_MODEL, DEFAULT_SHOTS, FALLTHROUGH_CONFIDENCE, RUBRIC, THRESHOLD, V1, V3, VERSION, api, generatedProvenance, permissionPathPrePass, routeRankPrePass, workers } = require('./grader-config.js');
87
+ const { ASSUMED_BUILDER_MODEL, DEFAULT_GRADER_MODEL, DEFAULT_SHOTS, FALLTHROUGH_CONFIDENCE, RUBRIC, THRESHOLD, V1, V3, VERSION, generatedProvenance, permissionPathPrePass, routeRankPrePass, workers } = require('./grader-config.js');
86
88
  const { clamp, isTrivialDiff, median, r1, selectGraderModel, workerModelFor } = require('./grader-model.js');
87
89
  const { buildPrompt, extractJson } = require('./grader-prompt.js');
88
90
  // task 1004011: the second grade species — a grade over the CLAIM on an
@@ -90,6 +92,10 @@ const { buildPrompt, extractJson } = require('./grader-prompt.js');
90
92
  const { GRADER_KIND: NO_ARTIFACT_GRADER_KIND, buildNoArtifactPrompt, gradeNoArtifact } = require('./grader-no-artifact.js');
91
93
  const { aggregateShots, changeMagnitude, hasPanelEvidence, isSubstantiveIssue, serverSideGuards, validateAndScore } = require('./grader-score.js');
92
94
  const { buildContextBundle, diffAddedLinesByFile, extractAssistantResult, extractCostUsd, runOneShot, runSubagent, runSubagentCached } = require('./grader-subagent.js');
95
+ // task 1002903: the severity gate's unbacked low scores, named once for the record.
96
+ const { unbackedLowScores } = require('./grader-workers/worker-verdict.js');
97
+ // task 1002839: the central engine the code panel below registers into (ADR 0089 §1).
98
+ const engine = require('./grade-engine.js');
93
99
 
94
100
  // task 1003313: render the WHY of a total outage into the verdict line.
95
101
  //
@@ -159,6 +165,18 @@ function aggregatePanel(workerResults, meta = {}) {
159
165
  const cqEffective = cq != null && economy != null ? r1(clamp(cq - economyPenalty, 0, 10)) : cq;
160
166
  const blended = ff != null && cq != null ? r1((ff + cq) / 2) : 0;
161
167
 
168
+ // task 1002903: low scores the severity gate found no major/blocker finding
169
+ // behind (worker-verdict.js applySeverityGatePolicy). Recorded, never hidden —
170
+ // the scores above keep them — so the card can say "nothing listed to fix".
171
+ const unbackedScores = unbackedLowScores(workerResults, { threshold: THRESHOLD });
172
+ // The two axes the SERVER gates on. validateAndScore re-checks ff and cq from
173
+ // the scores this panel forwards, so either one under threshold fails the ship
174
+ // whatever the verdict below says (see the gatingAllPass branch).
175
+ const serverGatedBelow = [
176
+ ...(ff != null && ff < THRESHOLD ? [`functional_fidelity ${ff}`] : []),
177
+ ...(cq != null && cq < THRESHOLD ? [`code_quality ${cq}`] : []),
178
+ ];
179
+
162
180
  // Verdict counts.
163
181
  const passCount = gateResults.filter((w) => w.verdict === 'pass').length;
164
182
  const concernsCount = gateResults.filter((w) => w.verdict === 'concerns').length;
@@ -356,6 +374,19 @@ function aggregatePanel(workerResults, meta = {}) {
356
374
  verdict = `CONCERNS — ${reasons.join('; ')} (manual-confirm available)`;
357
375
  panelOutcome = 'concerns';
358
376
  forwardedConfidence = FALLTHROUGH_CONFIDENCE;
377
+ } else if (gatingAllPass && serverGatedBelow.length > 0) {
378
+ // task 1002903: every gating worker passed, but a server-gated axis is under
379
+ // threshold. Today that is one shape — the ADVISORY Narc scored ff low with no
380
+ // finding, so the severity gate made it 'concerns' and the branch below would
381
+ // call the panel a PASS. The server then re-checks ff from the forwarded score
382
+ // and fails the ship anyway, so the stored grade said FAIL with a PASS verdict
383
+ // inside it. Say what will actually happen: manual-confirm, the same
384
+ // fall-through as every other concerns branch. The ship was already not
385
+ // passing, so this is no new gate — and an unbacked low score still does not
386
+ // pass, so it is no loosening either.
387
+ verdict = `CONCERNS — ${serverGatedBelow.join(' and ')} ${serverGatedBelow.length > 1 ? 'are' : 'is'} below ${THRESHOLD} with every gating worker passing; the server's score gate still applies (manual-confirm available)`;
388
+ panelOutcome = 'concerns';
389
+ forwardedConfidence = FALLTHROUGH_CONFIDENCE;
359
390
  } else if (gatingAllPass) {
360
391
  // Every gating worker passed. A Narc 'concerns' here is advisory only.
361
392
  verdict = `PASS — ${gating.length}/${gating.length} gating worker(s) cleared${advisoryConcerns ? ' (narc advisory: concerns)' : ''}`;
@@ -402,6 +433,9 @@ function aggregatePanel(workerResults, meta = {}) {
402
433
  if (efficiencyScore != null) lines.push(` efficiency_score (EffMng): ${efficiencyScore}/10`);
403
434
  lines.push(` blended: ${blended}/10 (threshold ${THRESHOLD}, gating workers must PASS; narc advisory — #481)`);
404
435
  lines.push(` forwarded confidence: ${forwardedConfidence ?? '?'} (panel: ${panelOutcome})`);
436
+ for (const u of unbackedScores) {
437
+ lines.push(` unbacked score: ${u.kind} ${u.axis} ${u.score}/10 is below ${THRESHOLD} with no major/blocker finding behind it (severity gate) — nothing listed to fix`);
438
+ }
405
439
  lines.push('');
406
440
  lines.push('Per-worker:');
407
441
  for (const w of workerResults) {
@@ -471,6 +505,9 @@ function aggregatePanel(workerResults, meta = {}) {
471
505
  fail_count: failCount,
472
506
  error_count: errorCount,
473
507
  panel_cost_usd: panelCostUsd, // #486: mirror into signals for the audit trail
508
+ // task 1002903: present only when there is one, so an ordinary grade's
509
+ // signals keep their exact shape.
510
+ ...(unbackedScores.length ? { unbacked_scores: unbackedScores } : {}),
474
511
  ...meta,
475
512
  },
476
513
  notes_md: lines.join('\n'),
@@ -531,96 +568,56 @@ function graderUnavailableResult({ reason, note, meta = {} } = {}) {
531
568
  };
532
569
  }
533
570
 
534
- // End-to-end v4 grader. Runs the worker panel through the cross-family
535
- // grader model and returns the panel-aggregated grade-result.
571
+ // ---------- the code panel as work-type #1 of the grade engine (task 1002839) ----------
536
572
  //
537
- // workers: defaults to DEFAULT_PANEL (narc + quality + hacker + efficiency —
538
- // the full 4-worker panel since Phase 2.1/2.2). An array of worker kinds
539
- // (strings) like ['narc', 'quality']. A default-panel run always satisfies
540
- // hasPanelEvidence's ≥3-worker floor, so an honest unanimous zero-issue
541
- // PASS is waived, never manual-confirm-gated (task 1002664 / ADR 0164).
542
- // builderModel: the model the builder used. Defaults to ASSUMED_BUILDER_MODEL
543
- // (Opus, per CLAUDE.md §2). Drives selectGraderModel — workers pick the
544
- // cross-family alternative.
545
- async function gradeSubagent({ task, valueSummary, diff, changedFiles, diffStats, workers: workerKinds, builderModel, builderRank, priorRound, runOpts = {} }) {
546
- // #943: bypass / disabled short-circuit — BEFORE resolving the panel or
547
- // spawning any worker. When the grader is globally bypassed (the
548
- // GRADER_BYPASS_ENABLED killswitch, ADR 0041) or a caller explicitly disables
549
- // it (runOpts.bypass), do NOT spawn the panel. Spawning into an outage/bypass
550
- // window is exactly what manufactured the "FAIL · 0/10" false-verdict in
551
- // session 1659 — the bypass was honored only DOWNSTREAM of the spawn. Return
552
- // the distinct 'unavailable' outcome so the result reads as "grader bypassed",
553
- // never a quality 0/10.
554
- if (runOpts.bypass === true || process.env.GRADER_BYPASS_ENABLED === '1') {
555
- const reason = runOpts.bypass === true ? 'bypass_requested' : 'grader_bypass_enabled';
556
- return graderUnavailableResult({
557
- reason,
558
- note: 'Grader bypassed before worker spawn (GRADER_BYPASS_ENABLED / runOpts.bypass) — no panel ran. Land via manual-confirm (/confirm).',
559
- meta: { builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL, bypassed: true },
560
- });
561
- }
562
- const panel = workers.resolvePanel(workerKinds ?? workers.DEFAULT_PANEL);
563
- const graderModel = selectGraderModel({ builderModel });
564
-
565
- // Deterministic route-rank pre-pass — its findings feed the Hacker as input;
566
- // its pseudo-worker result is appended to the panel below.
567
- const routeRank = routeRankPrePass(changedFiles);
568
- // Deterministic permission-path pre-pass (SEC #863) — a sub-Metic diff that
569
- // touches the rank/permission/ship-grade/deploy machinery hard-fails.
570
- const permPath = permissionPathPrePass(changedFiles, builderRank);
571
- const deterministicFindings = [
572
- ...(routeRank ? routeRank.findings : []),
573
- ...(permPath ? permPath.findings : []),
574
- ];
575
-
573
+ // ADR 0089 §1: the pipeline (select → pre_pass → evidence → fan_out → aggregate →
574
+ // gate → persist) lives in grade-engine.js and is identical for every work-type;
575
+ // a work-type supplies only what is its own. For code that is the v4 adversarial
576
+ // worker panel, and every hook below is what gradeSubagent's body did inline
577
+ // before the extraction — same calls, same arguments, same order. The engine
578
+ // owns the rest: the bypass short-circuit, grader-model selection, BOTH
579
+ // deterministic pre-passes (the code panel cannot skip them — nothing here names
580
+ // them), the parallel fan-out, the deterministic veto and the record meta.
581
+ const CODE_WORK_TYPE = Object.freeze({
582
+ key: 'code',
583
+ title: 'Code — the v4 adversarial worker panel (narc / quality / hacker / efficiency, + the advisory reader on copy-bearing ships)',
584
+ // Part of the llm-cache key: kept at the pre-engine value so every cached
585
+ // worker reply stays a hit.
586
+ cacheTaskType: 'grade_worker',
587
+ // The judges: an explicit list of worker kinds, else the full 4-worker
588
+ // panel. An unknown kind throws — a typo in a custom panel surfaces at once.
589
+ resolveJudges: (kinds) => workers.resolvePanel(kinds ?? workers.DEFAULT_PANEL),
590
+ // task 1002660: the rubric tier override per worker (cross-model-guarded).
591
+ modelFor: (worker, graderModel, builderModel) => workerModelFor(worker.KIND, graderModel, builderModel),
576
592
  // task 1002660: one read of the changed files, shared by all four workers —
577
593
  // kills the per-worker re-exploration that dominated panel cost.
578
594
  // diff (task 1002905): lets the bundle prove a truncated prefix actually shows
579
595
  // the change instead of handing a worker a head fragment as "post-change".
580
- const contextBundle = Object.assign(...await Promise.all([buildContextBundle({ changedFiles, cwd: runOpts.cwd, diff }), generatedProvenance.verifyGeneratedProvenanceAsync({ changedFiles, cwd: runOpts.cwd }).then((generatedProvenance) => ({ generatedProvenance }))])); // task 1002795 — verified once, concurrently with the bundle read, off the prompt path
581
- // task 1002660: cache-wrapped spawns. Same prompt + model replays at $0
582
- // (llm-cache, System 5) — the prompt embeds the diff + bundle, so the key
583
- // needs no separate scope sha. Absent llm-cache → plain runSubagent.
584
- const runSubagentPanelCached = ({ prompt, opts }) => runSubagentCached({
585
- prompt,
586
- opts,
587
- cache: api.llmCache
588
- ? api.llmCache.makeCacheSpec({ prompt, model: opts?.model, task_type: 'grade_worker' })
589
- : null,
590
- });
591
-
592
- const startedAt = Date.now();
593
- const workerPromises = panel.map((worker) => workers.runWorker(worker, {
594
- task,
595
- valueSummary,
596
- diff,
597
- changedFiles,
598
- diffStats,
599
- // task 1002660: rubric tier override per worker (cross-model-guarded).
600
- model: workerModelFor(worker.KIND, graderModel, builderModel),
601
- runOpts,
602
- runSubagentFn: runSubagentPanelCached,
603
- extractAssistantResultFn: extractAssistantResult,
604
- deterministicFindings: deterministicFindings.length ? deterministicFindings : null,
605
- priorRound: priorRound || null, // task 1002662: delta-aware re-grade context
606
- contextBundle, // task 1002660: post-change file contents, read once
607
- }));
608
- const workerResults = await Promise.all(workerPromises);
609
- // Append the deterministic checks as pseudo-workers so the any-fail gate
610
- // treats a write-route 'unknown' OR a sub-Metic permission-path edit as a
611
- // hard block with no gate-logic change.
612
- if (routeRank) workerResults.push(routeRank.workerResult);
613
- if (permPath) workerResults.push(permPath.workerResult);
614
- const totalMs = Date.now() - startedAt;
615
-
616
- return aggregatePanel(workerResults, {
617
- builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL,
618
- grader_model: graderModel,
619
- total_ms: totalMs,
620
- // task 1002905: what the panel could actually SEE, recorded on every grade.
621
- // Diagnosing the fabricated all-blocker FAIL cost a session because the grade
622
- // record said nothing about the read root or how much of each file the workers
623
- // were shown; a "the panel read the wrong tree" claim is now one field away.
596
+ gatherEvidence: async ({ changedFiles, diff, runOpts }) => ({
597
+ contextBundle: Object.assign(...await Promise.all([buildContextBundle({ changedFiles, cwd: runOpts.cwd, diff }), generatedProvenance.verifyGeneratedProvenanceAsync({ changedFiles, cwd: runOpts.cwd }).then((generatedProvenance) => ({ generatedProvenance }))])), // task 1002795 — verified once, concurrently with the bundle read, off the prompt path
598
+ }),
599
+ runJudge: (worker, ctx) => workers.runWorker(worker, {
600
+ task: ctx.task,
601
+ valueSummary: ctx.valueSummary,
602
+ diff: ctx.diff,
603
+ changedFiles: ctx.changedFiles,
604
+ diffStats: ctx.diffStats,
605
+ model: ctx.model,
606
+ runOpts: ctx.runOpts,
607
+ runSubagentFn: ctx.runSubagentFn,
608
+ extractAssistantResultFn: ctx.extractAssistantResultFn,
609
+ // Only the Hacker renders these; the route-rank + permission-path findings.
610
+ deterministicFindings: ctx.deterministicFindings.length ? ctx.deterministicFindings : null,
611
+ priorRound: ctx.priorRound || null, // task 1002662: delta-aware re-grade context
612
+ contextBundle: ctx.evidence.contextBundle, // task 1002660: post-change file contents, read once
613
+ }),
614
+ aggregate: aggregatePanel,
615
+ unavailable: graderUnavailableResult,
616
+ // task 1002905: what the panel could actually SEE, recorded on every grade.
617
+ // Diagnosing the fabricated all-blocker FAIL cost a session because the grade
618
+ // record said nothing about the read root or how much of each file the workers
619
+ // were shown; a "the panel read the wrong tree" claim is now one field away.
620
+ recordEvidence: ({ contextBundle }, { runOpts }) => ({
624
621
  context_bundle: {
625
622
  read_root: path.resolve(runOpts.cwd || process.cwd()),
626
623
  bundled: contextBundle.files.map((f) => ({
@@ -628,6 +625,29 @@ async function gradeSubagent({ task, valueSummary, diff, changedFiles, diffStats
628
625
  })),
629
626
  skipped: contextBundle.skipped,
630
627
  },
628
+ }),
629
+ });
630
+ engine.registerWorkType(CODE_WORK_TYPE, { isDefault: true });
631
+
632
+ // End-to-end v4 grader: the code panel through the grade engine. Returns the
633
+ // panel-aggregated grade-result.
634
+ //
635
+ // workers: defaults to DEFAULT_PANEL (narc + quality + hacker + efficiency —
636
+ // the full 4-worker panel since Phase 2.1/2.2). An array of worker kinds
637
+ // (strings) like ['narc', 'quality']. A default-panel run always satisfies
638
+ // hasPanelEvidence's ≥3-worker floor, so an honest unanimous zero-issue
639
+ // PASS is waived, never manual-confirm-gated (task 1002664 / ADR 0164).
640
+ // builderModel: the model the builder used. Defaults to ASSUMED_BUILDER_MODEL
641
+ // (Opus, per CLAUDE.md §2). Drives selectGraderModel — workers pick the
642
+ // cross-family alternative.
643
+ // bypass (#943): runOpts.bypass or GRADER_BYPASS_ENABLED returns the distinct
644
+ // 'unavailable' result BEFORE any worker spawns — see engine.runGrade.
645
+ async function gradeSubagent({ task, valueSummary, diff, changedFiles, diffStats, workers: workerKinds, builderModel, builderRank, priorRound, runOpts = {} }) {
646
+ return engine.runGrade({
647
+ workType: CODE_WORK_TYPE.key,
648
+ task, valueSummary, diff, changedFiles, diffStats,
649
+ judges: workerKinds,
650
+ builderModel, builderRank, priorRound, runOpts,
631
651
  });
632
652
  }
633
653
 
@@ -842,7 +862,8 @@ function gradeDeterministic({ task, verification, actualMinutes, valueSummary, l
842
862
  module.exports = {
843
863
  // v4 surface (active panel-based gate)
844
864
  aggregatePanel,
845
- gradeSubagent, // panel by default
865
+ gradeSubagent, // panel by default — the code work-type through the grade engine
866
+ gradeEngine: engine, // task 1002839: the central pipeline + work-type registry (ADR 0089 §1), code panel registered
846
867
  gradeNoArtifact, // task 1004011: the no-diff species — grades the CLAIM, not a diff
847
868
  buildNoArtifactPrompt, // exported so tests can read the prompt without spawning a model
848
869
  NO_ARTIFACT_GRADER_KIND, // task 1004011: 'subagent-no-artifact' — the species marker
@@ -207,6 +207,21 @@
207
207
  ${detail}
208
208
  </div>`;
209
209
  }
210
+ // LANDED, BUT GITHUB DID NOT GET IT (task 1004065). The runner pushes a hosted
211
+ // project's new pin to its repo after the move; when it could not, the move is still
212
+ // done — the site runs the new core — and `warning` says why the repo was not updated.
213
+ // Its own treatment, never the success one: "and the box confirmed it" on its own is
214
+ // the sentence that hid this while the box and the repo drifted apart.
215
+ if (m.state === 'done' && m.warning && m.target_version === p.served_version) {
216
+ return `
217
+ <div class="dp-settled dp-settled--warn">
218
+ <p class="ov-row__sub"><strong>Now running core ${escapeHtml(String(m.target_version))} — but GitHub was not updated.</strong>
219
+ Moved ${escapeHtml(ago(m.at))}, and the box confirmed it. The project’s repository on GitHub
220
+ still names the old core, so changes pushed there may not reach the live site until the two match.</p>
221
+ <p class="ov-row__sub">${escapeHtml(String(m.warning.text || ''))}</p>
222
+ ${reconnectHtml(p, m.warning.code)}
223
+ </div>`;
224
+ }
210
225
  if (m.state === 'done' && m.target_version === p.served_version) {
211
226
  return `
212
227
  <div class="dp-settled dp-settled--ok">
@@ -217,6 +232,26 @@
217
232
  return '';
218
233
  }
219
234
 
235
+ // The one fix a page can hand the owner for a repo that was not updated: sign in again
236
+ // with repository access, which stores the token the runner pushes with. Offered only
237
+ // for the two causes it can cure — a sign-in does nothing for GitHub being ahead of the
238
+ // box, or for a project with no repo. It says the access is short-lived (an hour, per
239
+ // GITHUB_TOKEN_TTL_MS) rather than promising the repo will simply catch up.
240
+ const RECONNECT_CURES = new Set(['no_access', 'refused']);
241
+ function reconnectHtml(p, code) {
242
+ if (!RECONNECT_CURES.has(code)) return '';
243
+ // Back to THIS page. An absolute https URL survives the callback's host (the
244
+ // allowlist admits the apex's subdomains); where there is none to read, the page's
245
+ // own path is the fallback.
246
+ let back = '';
247
+ try { back = String((window.location && window.location.href) || ''); } catch (e) { back = ''; }
248
+ if (!/^https:\/\//.test(back)) back = `/deploy${view.isSelf ? '' : `?project=${encodeURIComponent(String(p.slug))}`}`;
249
+ const href = `${API}/auth/web/start?repos=1&return=${encodeURIComponent(back)}`;
250
+ return `
251
+ <p class="ov-row__sub">If you own this project, <a href="${escapeHtml(href)}">reconnect GitHub with repository access</a>.
252
+ The hub keeps that access for about an hour, so the next upgrade run within the hour saves this change to GitHub along with its own.</p>`;
253
+ }
254
+
220
255
  function stepOneHtml(p) {
221
256
  return `
222
257
  <p class="ov-row__sub">Core <strong>${escapeHtml(String(p.recommended_version))}</strong> is published and
@@ -160,6 +160,13 @@
160
160
  border-left: 3px solid var(--danger);
161
161
  padding-left: 0.7rem;
162
162
  }
163
+ /* It landed, but the project's GitHub repo did not get the new pin (task 1004065). Not
164
+ the ok mark — the box and the repo now disagree — and not the danger one, because the
165
+ project is running exactly what was asked for. */
166
+ .dp-settled--warn {
167
+ border-left: 3px solid var(--warn);
168
+ padding-left: 0.7rem;
169
+ }
163
170
  /* A failure the box has already moved past (task 1004172). It keeps a mark, because it
164
171
  is still a thing that happened and worth reading — but not the danger mark, which on
165
172
  this page means "your project is in this state now". A two-day-old refusal wearing the
@@ -53,6 +53,11 @@ const githubPush = require('../github-push');
53
53
  const prAssign = require('../pr-assign');
54
54
  const shipAssurance = require('../ship-assurance');
55
55
  const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
56
+ // task 1003764: publish-status with no ?branch= and no PR titled for the task — the shape
57
+ // getPublishStatus gives a branch with no PR, plus how to ask about one. Never a 400.
58
+ const NO_PR_STATUS = Object.freeze({
59
+ pr_state: 'none', merged: false, merge_sha: null, deploy_status: 'none', pr_url: null, pr_number: null, pr_title: null, pr_body: null,
60
+ branch_hint: 'No pull request titled for this task is among the 100 most recent. Pass ?branch=<name> to ask about a specific branch.' });
56
61
  const seams = api;
57
62
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
58
63
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
@@ -1250,15 +1255,25 @@ module.exports = function buildTasksRouter() {
1250
1255
  // GET /tasks/:id/publish-status?branch=<branch> — poll the PR + prod-deploy
1251
1256
  // state for a server-mediated publish so ship.js can confirm the land WITHOUT
1252
1257
  // gh on the box. Ownership-gated like the POST. Derives state live from GitHub.
1258
+ // `branch` is optional (task 1003764): without it the server finds the task's own PR
1259
+ // (both publish paths title it "task <id>: …") and the answer names the `branch` it
1260
+ // used and its `branch_source`, so the check agents are sent to instead of git probes
1261
+ // never refuses a live publish for want of a branch name.
1253
1262
  router.get('/tasks/:id/publish-status', auth.requireBuilder, async (req, res) => {
1254
1263
  const id = parseId(req, res, { code: 'bad_task_id' });
1255
1264
  if (id === null) return;
1256
1265
  if (!(await gateTaskOwnership(req, res, id))) return;
1257
1266
  if (!githubPush.isConfigured()) return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
1258
- const branch = req.query.branch;
1259
- if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
1267
+ // A fully-qualified ref names the same branch, so it is accepted rather than refused.
1268
+ const asked = typeof req.query.branch === 'string' ? req.query.branch.replace(/^refs\/heads\//, '') : req.query.branch;
1269
+ if (asked != null && asked !== '' && !githubPush.isSafeBranch(asked)) {
1270
+ return res.fail('bad_branch', { status: 400, message: 'branch must be a plain branch name such as task-123, or left out so the server finds the task\'s own PR.' });
1271
+ }
1260
1272
  try {
1261
- const status = await githubPush.getPublishStatus({ branch });
1273
+ const found = asked ? null : await githubPush.findTaskPullRequest({ taskId: id });
1274
+ const branch = asked || (found && found.branch) || null;
1275
+ const branch_source = asked ? 'query' : (branch ? 'task_pr' : null);
1276
+ const status = branch ? await githubPush.getPublishStatus({ branch }) : NO_PR_STATUS;
1262
1277
  // task 1049 (B — server-side reconcile-on-read): when GitHub shows the PR
1263
1278
  // MERGED and deploy-prod SUCCEEDED, finish the task right here so a
1264
1279
  // confirmed-but-unshipped task never strands waiting on a human to re-poll
@@ -1318,7 +1333,7 @@ module.exports = function buildTasksRouter() {
1318
1333
  log.error('[gds] publish-status merge-driver (non-blocking):', e && e.message);
1319
1334
  }
1320
1335
  }
1321
- return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
1336
+ return res.json({ ok: true, ...status, branch, branch_source, reconciled, reconcile_blocked, merge_driver });
1322
1337
  } catch (e) {
1323
1338
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
1324
1339
  if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
@@ -74,10 +74,26 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
74
74
  // before landProvenForTask ever saw a recorded tip, so a commit provably on main
75
75
  // was refused as "not proven" (strands 1003285/1003286, task 1003434). Each step
76
76
  // now fails on its own: a status miss degrades to status=null, never to no-proof.
77
+ //
78
+ // The status probe needs a BRANCH (task 1004275). It used to be called with
79
+ // { taskId, publishedSha }, which getPublishStatus does not read, so it threw
80
+ // BAD_BRANCH every time and both PR-witness proofs in landProvenForTask (the
81
+ // squash/out-of-band land, and the no-tip fallback) could never fire on this path,
82
+ // while the reconciler proved the same land ~5 min later. So find the task's own PR
83
+ // first, by the "task <id>: …" title both publish paths give it (the same lookup
84
+ // GET /publish-status does since task 1003764), and ask about that branch. No PR,
85
+ // or a failed search, leaves status=null: the tip proof still runs.
77
86
  let publishedSha = null;
78
87
  try { publishedSha = await db.getClaimPublishedSha(taskId); } catch (e) { report(e); }
88
+ let branch = null;
89
+ try {
90
+ const found = await githubPush.findTaskPullRequest({ taskId });
91
+ branch = (found && found.branch) || null;
92
+ } catch (e) { report(e); }
79
93
  let status = null;
80
- try { status = await githubPush.getPublishStatus({ taskId, publishedSha }); } catch (e) { report(e); }
94
+ if (branch) {
95
+ try { status = await githubPush.getPublishStatus({ branch }); } catch (e) { report(e); }
96
+ }
81
97
  try {
82
98
  const proof = await githubPush.landProvenForTask({ taskId, publishedSha, status });
83
99
  if (proof && proof.proven) return { assurance: { kind: 'proof', reason: proof.reason } };
@@ -24,6 +24,8 @@
24
24
  // that file sits on the oversized-file ratchet; a guard whose whole value is being
25
25
  // read before it is edited should not be the thing that reds the build either.
26
26
 
27
+ const { PublicRefusal } = require('./public-refusal');
28
+
27
29
  // The only acts that cannot destroy the box they run on.
28
30
  //
29
31
  // core-upgrade — moves the pin, health-gated, auto-rollback on failure. This is
@@ -86,25 +88,24 @@ function intentAllowedForShape(action, hostingShape) {
86
88
  * @param {{query: Function}} db
87
89
  * @param {number|string} instanceId
88
90
  * @param {string} action an INTENT_ACTIONS member
89
- * @throws {Error} code 'control_plane_protected'
91
+ * @throws {PublicRefusal} code 'control_plane_protected', status 409
90
92
  */
91
93
  async function assertIntentAllowed(db, instanceId, action) {
92
94
  const { rows } = await db.query(
93
95
  'SELECT hosting_shape FROM provisioning_instances WHERE id = $1', [instanceId]);
94
96
  const shape = rows[0] && rows[0].hosting_shape;
95
97
  if (!shape || intentAllowedForShape(action, shape)) return;
96
- // It carries the { code, status, message } shape credential-preflight.js established,
97
- // so a route that wants to answer 409 with the sentence can, without re-deriving it.
98
- // NO ROUTE MAPS IT TODAY, and that is the honest state rather than an oversight: every
99
- // action that can reach this throw has its own earlier refusal, so arriving here means
100
- // a route forgot one — a bug in us, not a thing the caller did. A 500 plus a logged
101
- // stack is the right loudness for that, and the fields are here so the day a route
102
- // legitimately needs to say it to a person, the wording is already written.
103
- const err = new Error(`'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`);
104
- err.code = 'control_plane_protected';
105
- err.status = 409;
106
- err.publicMessage = `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`;
107
- throw err;
98
+ // A PublicRefusal, so every route answers it as a 409 in the owner's words (task
99
+ // 1004126). This used to rest on "every action that can reach this throw has its own
100
+ // earlier refusal", and that did not hold: teardown, force-teardown and disconnect reach
101
+ // it with none, and the owner read "internal error". Each route still logs the stack, so
102
+ // a route that forgot its own check stays as visible to us as it was.
103
+ throw new PublicRefusal({
104
+ code: 'control_plane_protected',
105
+ status: 409,
106
+ publicMessage: `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`,
107
+ message: `'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`,
108
+ });
108
109
  }
109
110
 
110
111
  module.exports = { CONTROL_PLANE_ACTIONS, intentAllowedForShape, assertIntentAllowed };
@@ -29,6 +29,7 @@
29
29
  // AT the 1500-line fitness ratchet, and at that ceiling a new function costs more
30
30
  // than the build will give back.
31
31
 
32
+ const { failFrom } = require('./public-refusal');
32
33
 
33
34
  // Exactly the states in which there is a running instance whose pin can move.
34
35
  // Narrower than RESTART_FROM on purpose: an instance mid-standup has no settled pin
@@ -209,18 +210,10 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
209
210
  });
210
211
  } catch (err) {
211
212
  log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
212
- // A REFUSAL IS NOT AN OUTAGE. The control-plane guard throws an error carrying
213
- // `code`, `status` and a `publicMessage` it wrote for exactly this moment — and
214
- // this catch used to discard all three, so every guard refusal reached the owner
215
- // as a 500 "internal error" while the real sentence sat in the journal. Nothing
216
- // in the repo honoured `publicMessage`; it was assigned once and read never
217
- // (task 1004125). Only an error that has already declared itself public is
218
- // forwarded — anything else still gets the opaque 500, because an unexpected
219
- // throw may carry anything.
220
- if (err && err.publicMessage && err.code && err.status) {
221
- return res.fail(err.code, { status: err.status, message: err.publicMessage });
222
- }
223
- return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
213
+ // A REFUSAL IS NOT AN OUTAGE (task 1004125): a guard refusal reaches the owner as
214
+ // its own sentence, and an unexpected throw, which may carry anything, stays the
215
+ // opaque 500. The one definition of "public" is public-refusal.js (task 1004126).
216
+ return failFrom(res, err, 'core_upgrade_failed');
224
217
  }
225
218
  };
226
219
  }
@@ -256,6 +249,23 @@ function lastMoveSuperseded(target, served, recommended) {
256
249
  return !!(recommended && String(recommended) !== String(target));
257
250
  }
258
251
 
252
+ /**
253
+ * The note a DONE move carries when the upgrade landed but its pin never reached the
254
+ * project's GitHub repo (task 1004065) — `{ code, text }`, or null. PURE.
255
+ *
256
+ * The runner writes it into the intent's last_error as `repo_not_updated:<code>: <text>`
257
+ * (scripts/gds/provision-core-upgrade.js repoNote; re-stated here because a module may
258
+ * not require a script, and round-tripped by tests/core_upgrade_runner.mjs). ONLY that
259
+ * shape is read: any other text on a done row is not turned into a warning, because
260
+ * inventing one would tell an owner their repo is stale on no evidence.
261
+ */
262
+ const REPO_NOTE_RE = /^repo_not_updated:([a-z_]+): ([\s\S]+)$/;
263
+ function parseRepoNote(text) {
264
+ const m = REPO_NOTE_RE.exec(String(text || ''));
265
+ // Bounded because it reaches a DOM, like the error beside it.
266
+ return m ? { code: m[1], text: m[2].slice(0, 600) } : null;
267
+ }
268
+
259
269
  /**
260
270
  * The cached dry-run verdict, IF it is about the move now on offer. PURE (task 1004173).
261
271
  *
@@ -344,8 +354,11 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
344
354
  // versions, and this is a version question wearing a different hat.
345
355
  superseded: lastMoveSuperseded(lastMove.target_version, served, recommended),
346
356
  // Truncated because it reaches a DOM and upgrade.js's refusals can carry a whole
347
- // `git status` (the dirty-tree one lists every file).
348
- error: lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
357
+ // `git status` (the dirty-tree one lists every file). Never set on a DONE move:
358
+ // a done row's last_error is the note below, and a move that worked is not an error.
359
+ error: lastMove.state !== 'done' && lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
360
+ // It LANDED, but GitHub did not get the new pin (task 1004065) — parseRepoNote.
361
+ warning: lastMove.state === 'done' ? parseRepoNote(lastMove.last_error) : null,
349
362
  } : null,
350
363
  };
351
364
  }
@@ -379,7 +392,7 @@ function makeCoreUpgradePreviewHandler({ provisioning, pool, parseId, log }) {
379
392
  return res.json({ ok: true, preview: await loadPreview(pool, provisioning, inst) });
380
393
  } catch (err) {
381
394
  log.error('[provisioning] GET /provisioning/instances/:id/core-upgrade', err);
382
- return res.fail('core_upgrade_preview_failed', { status: 500, message: 'internal error' });
395
+ return failFrom(res, err, 'core_upgrade_preview_failed');
383
396
  }
384
397
  };
385
398
  }
@@ -410,10 +423,10 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
410
423
  return res.json({ ok: true, preview: await loadPreview(pool, provisioning, rows[0]) });
411
424
  } catch (err) {
412
425
  log.error('[provisioning] GET /provisioning/control-plane', err);
413
- return res.fail('control_plane_read_failed', { status: 500, message: 'internal error' });
426
+ return failFrom(res, err, 'control_plane_read_failed');
414
427
  }
415
428
  };
416
429
  }
417
430
 
418
- module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
431
+ module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
419
432