@bongos/core 1.19.48 → 1.19.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.48",
6
- "core_contract": "1.19.48",
7
- "source_commit": "55b3c049db775bb60d3749a998a44110a340f3fa",
5
+ "core_version": "1.19.50",
6
+ "core_contract": "1.19.50",
7
+ "source_commit": "1a7b11b4377e9fefb36560c0cda9d4b1030a05c9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T18:28:57.362Z",
9
+ "built_at": "2026-08-11T18:44:57.114Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 290,
12
+ "docs_redacted": 291,
13
13
  "agent_docs_stubbed": 21,
14
14
  "functional_verbatim": 1217,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1528,
20
- "tree_sha256": "be96d3ccab1f445af630ea28b9609a9dc3dfba76aa75b5bc394a97e9d9a1d166",
19
+ "file_count": 1529,
20
+ "tree_sha256": "e96a0d29e2d6adc516aaa0d095dbb1c4d543dbec0508228b2cbcf16a1b400dbd",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1294,10 +1294,15 @@
1294
1294
  "mode": "0000644",
1295
1295
  "sha256": "64fb6e129cf5484266e1b6a9cddd8ba88ff7438b9646c3f4d9ffd0cda3cce642"
1296
1296
  },
1297
+ {
1298
+ "path": "docs/adr/0165-instances-hookless-by-default.md",
1299
+ "mode": "0000644",
1300
+ "sha256": "d422e10adc391127a7f39b9eb8b5927c149ee455402bc5e4641f55e8b31b2d15"
1301
+ },
1297
1302
  {
1298
1303
  "path": "docs/adr/README.md",
1299
1304
  "mode": "0000644",
1300
- "sha256": "ceb8639779cb8f8f43c261800607ff47b90a70489d8f5b5c7e8e0e2c5244bdb1"
1305
+ "sha256": "09a7ba734571fbb304c60a488fe9ccdb6ca06d01127cfb87f07b1cbb54d69408"
1301
1306
  },
1302
1307
  {
1303
1308
  "path": "docs/api-reference.md",
@@ -1327,7 +1332,7 @@
1327
1332
  {
1328
1333
  "path": "docs/canonical-permissions.md",
1329
1334
  "mode": "0000644",
1330
- "sha256": "3cc48fe7b291976d819450202090b0df0c165d2470f5895a16febb7cdd5834a2"
1335
+ "sha256": "b7f66c6f1fbed274e5ba3295c06db8fc8edb84edd44f6ce92f6734e18267e3d9"
1331
1336
  },
1332
1337
  {
1333
1338
  "path": "docs/design-contract.md",
@@ -1567,7 +1572,7 @@
1567
1572
  {
1568
1573
  "path": "docs/recipes/private-npm-distribution.md",
1569
1574
  "mode": "0000644",
1570
- "sha256": "1aafe7dafea94084c381e7ea1e91fad533ca379f9560da6f9f9f4a2cf4db4fc5"
1575
+ "sha256": "5bfca9628859f1cdd79d741bdd50f3fe415430ee3380a2ac7f46b97ebe082d0e"
1571
1576
  },
1572
1577
  {
1573
1578
  "path": "docs/recipes/search-index-ingestion.md",
@@ -2857,12 +2862,12 @@
2857
2862
  {
2858
2863
  "path": "modules/grading/grader-rubric.json",
2859
2864
  "mode": "0000644",
2860
- "sha256": "2e080d8d50383a24d77f9cf166f6e93e05e7bccc8775d14bbf68f2d6733eecd4"
2865
+ "sha256": "6a0a2205fee2a4b0efc422c884826a9fd2af3d8785b393d5f7123e1758f1ebfc"
2861
2866
  },
2862
2867
  {
2863
2868
  "path": "modules/grading/grader-workers/efficiency.js",
2864
2869
  "mode": "0000644",
2865
- "sha256": "3e3721289ceb8c9c14e7aff7dc9569cbf01be3f2363bcaedc04aaee01d3fca3a"
2870
+ "sha256": "ab85c9c7b167e710b07af1d4beb1579a593aa617e1c1990238d329fdb0362035"
2866
2871
  },
2867
2872
  {
2868
2873
  "path": "modules/grading/grader-workers/extract-json.js",
@@ -2877,22 +2882,22 @@
2877
2882
  {
2878
2883
  "path": "modules/grading/grader-workers/hacker.js",
2879
2884
  "mode": "0000644",
2880
- "sha256": "650914c1f726c02ee19c80f72676c2bf86d99b9aded6cff1210905db52cb48d8"
2885
+ "sha256": "c7177625382e2ea61ee706c6b9b8669d9d452cdc0a92235695438b249c1ab6fc"
2881
2886
  },
2882
2887
  {
2883
2888
  "path": "modules/grading/grader-workers/index.js",
2884
2889
  "mode": "0000644",
2885
- "sha256": "232dc6b3c2d97282cce8032a1c671c89bb8e1e8936b4bb0acbd3ec4471e74f0a"
2890
+ "sha256": "8ec5e91341ec5247ee643c6ce75fb5ec21a62b3bba176fdde060cdb352edb808"
2886
2891
  },
2887
2892
  {
2888
2893
  "path": "modules/grading/grader-workers/narc.js",
2889
2894
  "mode": "0000644",
2890
- "sha256": "eeb9545d638d58958049c7889401f1cd49a2fb03607c25bfbef3625a0c7dd9fd"
2895
+ "sha256": "f35c3fce7feb6198df0011a9d22533038ca0766733c97a31841a184251800d39"
2891
2896
  },
2892
2897
  {
2893
2898
  "path": "modules/grading/grader-workers/quality.js",
2894
2899
  "mode": "0000644",
2895
- "sha256": "fa73a1d68c0126a2c109e9b7ef8692df29cbb41bbb800ab64e127f20e0a5865e"
2900
+ "sha256": "003b51b4a5eab34bb634ceaad5ae9c1ae6f2566423fe0d15cef79b936dc6756b"
2896
2901
  },
2897
2902
  {
2898
2903
  "path": "modules/grading/grader-workers/rubric.js",
@@ -2902,7 +2907,7 @@
2902
2907
  {
2903
2908
  "path": "modules/grading/grader-workers/worker-prompt.js",
2904
2909
  "mode": "0000644",
2905
- "sha256": "9821a36e9a11be805cf806d83edd4be8cf8c0b5d40fe63276b50f18d8eff8a74"
2910
+ "sha256": "5b614e96ed951c5eebb5c40c93612f59ff79da768055233b59790599421a70ec"
2906
2911
  },
2907
2912
  {
2908
2913
  "path": "modules/grading/grader-workers/worker-verdict.js",
@@ -2912,7 +2917,7 @@
2912
2917
  {
2913
2918
  "path": "modules/grading/grader.js",
2914
2919
  "mode": "0000644",
2915
- "sha256": "f6b80df54bb7634a9effb1cbe51b3ff99b05b0b7912be5a06690f63d237689f9"
2920
+ "sha256": "edf6ecc725b37fe2c515174e6b06025f82fae10172e6f13d8fcfbb060c11474a"
2916
2921
  },
2917
2922
  {
2918
2923
  "path": "modules/grading/module.json",
@@ -4032,12 +4037,12 @@
4032
4037
  {
4033
4038
  "path": "package-lock.json",
4034
4039
  "mode": "0000644",
4035
- "sha256": "132fd0ff1d19c5dc9ed4fddbcab4d6ca1754214e00da32fcd4bf06fd42cf0b05"
4040
+ "sha256": "ffd1f91d40f2098324c3054cd3accdfdf210a668972dcb8702104cd11ed6c4dc"
4036
4041
  },
4037
4042
  {
4038
4043
  "path": "package.json",
4039
4044
  "mode": "0000644",
4040
- "sha256": "f8aec45507128cd1060537a8aae479db0806b9adbc9f2b1024e601fe181bce32"
4045
+ "sha256": "cd444deb3940b96b8afedb89f5ece513d842d4a9148b3a50304d8d77866dd904"
4041
4046
  },
4042
4047
  {
4043
4048
  "path": "public-docs/index.html",
@@ -4952,7 +4957,7 @@
4952
4957
  {
4953
4958
  "path": "scripts/gds/npmrc.js",
4954
4959
  "mode": "0000644",
4955
- "sha256": "115af471c497515927528927da78793400e7181476c23c724479bccd079baa3c"
4960
+ "sha256": "9dfd27e75def392a73d7de7fd23d462afc97b17a8199acfe6563a80692570026"
4956
4961
  },
4957
4962
  {
4958
4963
  "path": "scripts/gds/oauth-secret.js",
@@ -5227,7 +5232,7 @@
5227
5232
  {
5228
5233
  "path": "scripts/gds/ship.js",
5229
5234
  "mode": "0000644",
5230
- "sha256": "94f343beba33727cb83367555c35a699f53c8efa5d59e3b65d3f6a1cf3e0e3b0"
5235
+ "sha256": "aa77aef41188c5ef596c9c5e7e89f54f97c93b6e889e1c776b8fe34c8af66cce"
5231
5236
  },
5232
5237
  {
5233
5238
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5657,7 +5662,7 @@
5657
5662
  {
5658
5663
  "path": "src/module-api.js",
5659
5664
  "mode": "0000644",
5660
- "sha256": "59777605e64fb494479971b94c25cf91ef601c21b5f4153d0856d6281c31db39"
5665
+ "sha256": "2d8b78d34893b1e4c25d081c343ad1b0d1deb2a675cae9a3c4e96500c6cea821"
5661
5666
  },
5662
5667
  {
5663
5668
  "path": "src/module-loader/loader.js",
@@ -6627,7 +6632,7 @@
6627
6632
  {
6628
6633
  "path": "tests/grade_diff_hash.mjs",
6629
6634
  "mode": "0000644",
6630
- "sha256": "a661c482da391651dbf85fe92fe2a588802f8855560251cdc2074ae1b46d4425"
6635
+ "sha256": "170b816aa9032e6eccba415f67ce60c10da5f2f706a1b1ee458a5d8578679535"
6631
6636
  },
6632
6637
  {
6633
6638
  "path": "tests/grade_server_authoritative.mjs",
@@ -6642,7 +6647,7 @@
6642
6647
  {
6643
6648
  "path": "tests/grader.mjs",
6644
6649
  "mode": "0000644",
6645
- "sha256": "227d71aaab363d9346c3034f41cfb518e866b49bb460802b32626f288ecdcd76"
6650
+ "sha256": "31557b08f8886f5d76201b5afbe0332e834504b67a391699798170aede9541e8"
6646
6651
  },
6647
6652
  {
6648
6653
  "path": "tests/grader_bypass.mjs",
@@ -0,0 +1,40 @@
1
+ # 0165 — Instances are hookless by default: no scaffolded `.husky/`; local hooks stay an instance-owned opt-in
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-11
5
+ - **Deciders:** Claude (jaxri session), under the bug-wave mandate — flagged owner-adjacent; the ADR is the revisit point
6
+
7
+ **Track:** `internal` (provisioning / trust boundary). Task [1002641](https://cloudbongos.com/builders#/task/1002641), from idea 1000655. Relates to [ADR 0043](<redacted>.md) (Layer 1), [ADR 0055](<redacted>.md) (server-mediated publish), [ADR 0162](<redacted>.md) (CI-only gates).
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ Task 1002608 delivered `.husky/pre-push` — the ADR 0043 **Layer 1** local permission gate — for **this core repo**, and `scripts/gds/install-git-hooks.js` (run by `/builder-setup`) activates whatever hooks a clone's `.husky/` tracks by setting `core.hooksPath`.
14
+
15
+ But nothing delivers Layer 1 to a **scaffolded standalone instance**: `init.js` / `provision.js` / `onboard.js` materialize no `.husky/`, and `publish-manifest.js` excludes `.husky/` from the published core artifact **by design** (`PROTECTED_TOPOLOGY_EXCLUDE` — a core update must never silently rewrite an instance's local gate topology). So instances run with no pre-push permission gate and no pre-commit secrets hook. Task 1002641 asks: is that a gap to fill, or the accepted posture?
16
+
17
+ ## Decision
18
+
19
+ **Hookless-by-default is the accepted posture for instances. Provisioning scaffolds no `.husky/`. Local hooks are an instance-owned opt-in: an instance that wants them commits its own `.husky/`, and the core's existing `install-git-hooks.js` activates them on every builder's machine.**
20
+
21
+ Four structural reasons:
22
+
23
+ 1. **The population Layer 1 constrains holds no push credential on an instance.** Sub-Metic builders work dev boxes where the git origin is credential-less and landing goes through **server-mediated publish** (ADR 0053/0055): the box uploads a bundle, the *server* pushes and opens the PR. A client-side pre-push hook on the box guards a push that cannot happen. Metic+ builders with real push access are inside the trust boundary, and their pushes meet the server-side gates that actually enforce: branch protection, the required `unit` check (fitness, gate-review tests, the authorship gate — task 1002640), and the every-push `main-audit`.
24
+ 2. **A client hook is not a security boundary** (ADR 0016: the DB and server enforce; local edits grant nothing). On the core repo, `.husky/pre-push` is defense-in-depth for contributors with direct push access — a materially different population than instance builders. Scaffolding it onto instances would *look* like a control while a hostile clone deletes it in one command.
25
+ 3. **The core cannot maintain what it deliberately does not ship.** `.husky/` is `PROTECTED_TOPOLOGY_EXCLUDE`d from the artifact, so scaffolded hooks would be born orphans — no upgrade path, rotting silently until they misfire. A written control that no longer runs teaches builders to ignore controls (the audit-authorship lesson, task 1002640).
26
+ 4. **ADR 0162 (owner directive): gates live in CI, rank-consistent and hands-free.** Instance-side client gates re-introduce the blocked-builder friction class that ADR retired — a broken hook strands a legitimate land until a human intervenes.
27
+
28
+ **Secrets hooks follow the same rule.** A pre-commit secrets guard needs per-machine tooling and gives false local confidence; the PR-time CI secret scan is the enforcing control on every land.
29
+
30
+ **The opt-in stays real, and stays instance-owned.** `install-git-hooks.js` ships in the core and already runs at `/builder-setup` on every instance; it activates whatever `.husky/` the *instance's own repo* tracks. Policy (which hooks, if any) is the instance's; the activation mechanism is the core's. That split mirrors the branding contract (ADR 0062 §7).
31
+
32
+ ## Rejected
33
+
34
+ - **Scaffold `.husky/` wrappers at `init`/provision** — orphaned gate topology the core can't update (reason 3), guarding a push path instance builders don't have (reason 1).
35
+ - **An `init --with-hooks` flag** — speculative surface; no instance has asked. If one does, it composes trivially on the opt-in path above.
36
+ - **Treating hooklessness as a security regression to hotfix** — the enforcing controls on instances are server-side and already in place; Layer 1 was always the advisory outermost layer, not the wall.
37
+
38
+ ## When to revisit
39
+
40
+ An instance model where builders routinely push directly with their own credentials (no server-mediated publish, no branch protection) would remove reasons 1–2; revisit then.
@@ -237,5 +237,6 @@ This keeps the decision history honest and traceable.
237
237
 
238
238
  | 0163 | [**Pre-197 display names: a transitional 'legacy' source, resolved at first sign-in** ([task 1002643](https://cloudbongos.com/builders#/task/1002643); idea 1000659). Amends [ADR 0156](<redacted>.md), whose migration defaulted every pre-existing row to `'github'` — false for builders who renamed after their last sign-in but before 197 landed, so their names kept reverting. No audit trail exists to backfill precisely, so `core_214` marks every pre-197 `'github'` row `'legacy'` (scoped by 197's `applied_at` on the instance — portable) and the upsert resolves each at its first sign-in: stored = incoming GitHub name → provably GitHub-derived → `'github'`; differs → treated as builder-set → kept, stamped `'user'`. Accepted misclassification: a GitHub-side rename in the window freezes a stale label (visible, self-fixable in Settings) — preferred over silently destroying deliberate renames. Rejected: `display_name <> github_login` heuristic (freezes nearly everyone), comparing forever on all `'github'` rows (turns every future GitHub rename into a frozen name).](<redacted>.md) | GDS / builder experience |
239
239
  | 0164 | [**The implausible-pass guard reads substance + evidence; the Metic+ honor-system residual is explicit** ([task 1002658](https://cloudbongos.com/builders#/task/1002658); grader-hardening goal 1000056). The guard fired only on `issues.length===0` — one fabricated `{}` defeated it, its magnitude inputs are client-supplied, and every in-sample firing (eval 2-for-2, tasks 1002643 ×2, 1002663) was a false positive on an honest unanimous zero-issue panel PASS. Redesign: count only SUBSTANTIVE issues (known severity + non-empty summary — padding trips the guard regardless), and waive the zero-issue pass when `signals.workers` carries the full &ge;3-worker panel shape, stamped `implausible_pass_waived` for /grade-audit. The residual is named, not hidden: every grade-time input is client-authored, so plausibility at grade time is honest-mistake hygiene; the binding authorities on the real diff are land-time (CI unit, gate-review, main audit) — Metic+ grade-time honesty is accepted honor-system per this ADR. The ADR-0042 CI grade-gate (land it vs record permanent deferral) goes to the owner as a blocker, not decided here. No approval step added or removed (ADR 0162).](<redacted>.md) | GDS / grading |
240
+ | 0165 | [**Instances are hookless by default: no scaffolded `.husky/`; local hooks stay an instance-owned opt-in** ([task 1002641](https://cloudbongos.com/builders#/task/1002641); idea 1000655). Task 1002608 delivered the ADR 0043 Layer 1 pre-push gate for the CORE repo, but nothing materializes `.husky/` on a scaffolded standalone instance (`publish-manifest.js` excludes it by design). Decision: that is the accepted posture, not a gap. Sub-Metic instance builders hold no push credential at all (server-mediated publish, ADR 0053/0055) — a client pre-push hook guards a push that cannot happen; a client hook is not a security boundary (ADR 0016); the core cannot maintain hooks it deliberately does not ship (scaffolded hooks would be born orphans — the audit-authorship rot lesson, task 1002640); and ADR 0162 puts gates in CI, rank-consistent and hands-free. Secrets hooks follow the same rule (the PR-time CI secret scan enforces). The opt-in stays real and instance-owned: an instance commits its own `.husky/` and the core's `install-git-hooks.js` (already run by `/builder-setup`) activates it. Rejected: scaffolding wrappers at init, an `--with-hooks` flag (speculative), treating hooklessness as a regression. Revisit if an instance model has builders pushing directly with their own credentials.](<redacted>.md) | provisioning / trust boundary |
240
241
 
241
242
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -265,7 +265,9 @@ pre-merge gate misses, independent of the builder's cooperation:
265
265
  [`src/bongos/permission-path-check.js`](../src/bongos/permission-path-check.js) (source of truth).
266
266
  - **Nobody pushes `main` directly.** The pre-push hook (`.husky/pre-push`, tracked in this repo and
267
267
  activated per-clone by `/builder-setup` via `install-git-hooks.js` — inert on a clone that never
268
- activated it; ADR 0043 §"G-2/G-17 resolution") blocks it; the graded
268
+ activated it; ADR 0043 §"G-2/G-17 resolution") blocks it. This hook is a CORE-repo control:
269
+ scaffolded standalone instances are hookless by default — deliberate, not a gap
270
+ ([ADR 0165](adr/<redacted>.md)); the graded
269
271
  pipeline pushes only via `scripts/gds/push-main.js` (which carries the `OTB_ALLOW_MAIN_PUSH`
270
272
  override). A raw push is caught and reverted by the **post-push main audit**
271
273
  (`scripts/gds/main-audit.js`) — nightly and as the `~/deploy.sh` deploy gate — which is the
@@ -20,7 +20,7 @@ Both carry `publishConfig: { access: "restricted" }`, so **publishing them is pr
20
20
  These involve payment and permanent, account-level actions. They are **not** done inside a build session.
21
21
 
22
22
  1. **Put the `bongos` npm org on a paid plan.** Private scoped publishing requires it. On npmjs.com → your org → Billing → upgrade to a plan that allows private packages (npm Teams, ~$7/member/month). Add as members only the people who need to publish or install during pre-launch — each is a billed seat.
23
- 2. **Create an access token.** npmjs.com → Access Tokens → *Generate New Token* → **Classic → Automation** (bypasses 2FA, which npm requires to publish), with **read-write** on the `@bongos` packages — a **read-only** token is enough for machines that only install. Store it as an environment variable, never in a committed file:
23
+ 2. **Create an access token.** npm retired classic (Automation/Publish) tokens — use a **granular access token**: npmjs.com → Access Tokens → *Generate New Token* → **Granular Access Token**, scoped to the `@bongos` packages with **Read and write** permission, and — for a token that publishes — enable **"Bypass two-factor authentication"** (npm otherwise requires interactive 2FA at publish). A **Read-only** granular token is enough for machines that only install. Granular tokens **expire** (pick the longest expiry the policy allows and calendar the rotation — a silently expired token is a 404 on the private package, the same symptom as a missing one). Store it as an environment variable, never in a committed file:
24
24
  ```sh
25
25
  export NPM_TOKEN=<redacted>
26
26
  ```
@@ -59,7 +59,7 @@ The steps below remain as the **manual fallback** (lane disarmed, npm outage, or
59
59
  2. **Publish** (owner, from a clean checkout of the released commit):
60
60
  ```sh
61
61
  node scripts/gds/package-core.js --publish # DRY-RUN: runs the fail-closed no-leak gate, previews @bongos/core@<version>
62
- npm login # once per machine (+ 2FA), or use a Classic Automation token
62
+ npm login # once per machine (+ 2FA), or use a granular token with 2FA bypass
63
63
  node scripts/gds/package-core.js --publish --live # permanent; the no-leak gate must pass or nothing is built/uploaded
64
64
  ```
65
65
  The no-leak / mirror-redact gate is **built into the build** — a leaking artifact is never produced, so `--publish` cannot upload one.
@@ -89,7 +89,7 @@ Without a valid token + org membership the install 404s — that is the private
89
89
 
90
90
  Provisioning defaults to installing the core from a **vendored tarball** committed into each instance repo (task 2053) — offline, needs no token, and stays the default/fallback until the registry path is proven on a real greenfield standup. The registry pull is **opt-in and fail-soft**:
91
91
 
92
- - **Turn it on** by setting `PROVISION_CORE_FROM_REGISTRY=1` in the control-plane runner's environment (e.g. `/etc/cloudbongos/box.env`) **and** providing a read-only `NPM_TOKEN` (a Classic **Automation** token — read-only — from the paid `bongos` org). With both present, the standalone scaffold pins `@bongos/core@<version>` from the registry and writes an env-fed `.npmrc` (`_authToken=<redacted> instead of vendoring a tarball; `npm ci` then pulls the private package (the token is inherited from the runner env — never committed).
92
+ - **Turn it on** by setting `PROVISION_CORE_FROM_REGISTRY=1` in the control-plane runner's environment (e.g. `/etc/cloudbongos/box.env`) **and** providing a read-only `NPM_TOKEN` (a **granular access token** — Read-only on the `@bongos` packages — from the paid `bongos` org; mind its expiry). With both present, the standalone scaffold pins `@bongos/core@<version>` from the registry and writes an env-fed `.npmrc` (`_authToken=<redacted> instead of vendoring a tarball; `npm ci` then pulls the private package (the token is inherited from the runner env — never committed).
93
93
  - **Fail-soft**: if the flag is set but `NPM_TOKEN` is absent, provisioning logs a notice and **falls back to the vendored tarball**, so a tokenless standup still works.
94
94
  - **Directly**: `bongos init --registry-core` scaffolds the registry shape (caret pin + `.npmrc`; generates the lockfile when `NPM_TOKEN` is set). `bongos upgrade --to <v> --registry` bumps a consumer to a registry version. Both are mutually exclusive with the vendored `--vendor-core` / `--from` inputs.
95
95
 
@@ -12,6 +12,8 @@
12
12
  "assumed_builder_model": "claude-fable-5",
13
13
  "model_refresh_note": "task 1002659 (2026-08-11): pins refreshed to the Claude 5 family. Assumed builder = claude-fable-5 (the owner-top-model default per CLAUDE.md §2); default grader = claude-sonnet-5 (cross-model per ADR 0024, cost-parity with the prior sonnet-4-6 posture — opus-5 grades the sonnet/haiku builders where the grader should be the stronger model). selectGraderModel no longer throws on an unknown builder id: it warns loudly and falls back to default_model (the #117 throw stranded ships whose builder id lacked an opus/sonnet/haiku substring — exactly what claude-fable-5 is).",
14
14
  "active_panel": ["narc", "quality", "hacker", "efficiency"],
15
+ "worker_model_tiers": { "quality": "claude-haiku-4-5-20251001" },
16
+ "worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens runs on the haiku tier. workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically.",
15
17
  "fallthrough_confidence": 0.3,
16
18
  "low_confidence_threshold": 0.4,
17
19
  "rationale": "GDS-V3 task #410 (MAS Phase 2.0), 2026-05-29. Supersedes v3 (single-prompt 3-shot generalist). Phase 1 calibration showed minimal between-shot disagreement on the same prompt — the strict gate fired correctly but the diversity signal was weak. v4 trades sampling-variance for lens-specialization: replace 3 identical-prompt shots with N workers (each a distinct prompt focused on one axis). Phase 2.0 ships 2 of the 3 planned workers (narc for completion, quality for cq + advisory economy) — keeps the gate at the same strictness while the framework calibrates. Phase 2.1 adds hacker (security; wraps /builder-redteam as a CI gate). Phase 2.2 adds efficiency_monger.",
@@ -107,7 +107,7 @@ No prose, no markdown fences:
107
107
 
108
108
  You have a Read tool with a hard budget of 5 calls. Do not run tests, do not edit files, do not call the network.`;
109
109
 
110
- function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound }) {
110
+ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound, contextBundle }) {
111
111
  const t = task || {};
112
112
 
113
113
  // Efficiency Monger shows diff size as informational context (its axis is
@@ -127,6 +127,7 @@ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorR
127
127
 
128
128
  return buildWorkerPrompt({
129
129
  priorRound,
130
+ contextBundle, // task 1002660: shared post-change file contents
130
131
  header: PROMPT_HEADER,
131
132
  task: t,
132
133
  valueSummary,
@@ -117,7 +117,7 @@ No prose, no markdown fences:
117
117
 
118
118
  You have a Read tool with a hard budget of 5 calls. Do not run tests, do not edit files, do not call the network.`;
119
119
 
120
- function buildPrompt({ task, valueSummary, diff, changedFiles, deterministicFindings, priorRound }) {
120
+ function buildPrompt({ task, valueSummary, diff, changedFiles, deterministicFindings, priorRound, contextBundle }) {
121
121
  const t = task || {};
122
122
  const findings = Array.isArray(deterministicFindings) ? deterministicFindings : [];
123
123
 
@@ -155,6 +155,7 @@ function buildPrompt({ task, valueSummary, diff, changedFiles, deterministicFind
155
155
 
156
156
  return buildWorkerPrompt({
157
157
  priorRound,
158
+ contextBundle, // task 1002660: shared post-change file contents
158
159
  header: PROMPT_HEADER,
159
160
  task: t,
160
161
  valueSummary,
@@ -108,14 +108,15 @@ function resolvePanel(kinds) {
108
108
  // without knowing about the runSubagent internals. ("shot" is v3 multi-shot
109
109
  // terminology; v4 calls these workers — but the subprocess plumbing is the
110
110
  // same shape underneath.)
111
- async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffStats, model, runOpts, runSubagentFn, extractAssistantResultFn, deterministicFindings, priorRound }) {
111
+ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffStats, model, runOpts, runSubagentFn, extractAssistantResultFn, deterministicFindings, priorRound, contextBundle }) {
112
112
  // deterministicFindings (route-rank pre-pass, #475) is forwarded to every
113
113
  // worker's buildPrompt; only the Hacker reads it — the others ignore the
114
114
  // extra field, so this is a no-op for narc/quality/efficiency.
115
115
  // priorRound (task 1002662) rides the same way: the prior grade's findings +
116
116
  // builder response for a delta-aware re-grade; every worker renders it via
117
- // the shared scaffold.
118
- const prompt = worker.buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, deterministicFindings, priorRound });
117
+ // the shared scaffold. contextBundle (task 1002660) likewise: the post-change
118
+ // file contents read once by the caller, rendered by the shared scaffold.
119
+ const prompt = worker.buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, deterministicFindings, priorRound, contextBundle });
119
120
 
120
121
  // One subagent attempt → verdict. Pulled out so #465 can re-run it once on a
121
122
  // transient error without duplicating the spawn/parse/verdict plumbing.
@@ -91,7 +91,7 @@ No prose before or after, no markdown fences:
91
91
 
92
92
  You have a Read tool with a hard budget of 5 calls. Do not run tests, do not edit files, do not call the network.`;
93
93
 
94
- function buildPrompt({ task, valueSummary, diff, changedFiles, priorRound }) {
94
+ function buildPrompt({ task, valueSummary, diff, changedFiles, priorRound, contextBundle }) {
95
95
  const t = task || {};
96
96
  // Narc's description fallback is uniquely "(no description — set confidence
97
97
  // LOW)" (the others use "(no description)"); the shared scaffold defaults to
@@ -100,6 +100,7 @@ function buildPrompt({ task, valueSummary, diff, changedFiles, priorRound }) {
100
100
  // stays byte-identical to the inlined version.
101
101
  return buildWorkerPrompt({
102
102
  priorRound,
103
+ contextBundle, // task 1002660: shared post-change file contents
103
104
  header: PROMPT_HEADER,
104
105
  task: { ...t, description: t.description || '(no description — set confidence LOW)' },
105
106
  valueSummary,
@@ -80,7 +80,7 @@ No prose, no markdown fences:
80
80
 
81
81
  You have a Read tool. Spend reads checking that removed code is unreferenced elsewhere, that new helpers match surrounding style, and that invariants the diff touches are still preserved in the post-merge state. Do not run tests, do not edit files, do not call the network.`;
82
82
 
83
- function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound }) {
83
+ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound, contextBundle }) {
84
84
  const t = task || {};
85
85
 
86
86
  // Quality owns a diff-size section (the economy axis) before the diff.
@@ -101,6 +101,7 @@ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorR
101
101
 
102
102
  return buildWorkerPrompt({
103
103
  priorRound,
104
+ contextBundle, // task 1002660: shared post-change file contents
104
105
  header: PROMPT_HEADER,
105
106
  task: t,
106
107
  valueSummary,
@@ -62,7 +62,7 @@ const REGRADE_DELTA_POLICY = [
62
62
  function buildWorkerPrompt({
63
63
  header, task, valueSummary, diff, changedFiles,
64
64
  taskHeader, descLabel, emptyFilesText, readFilesHint, finalInstruction,
65
- afterKindLines = [], beforeDiffLines = [], priorRound = null,
65
+ afterKindLines = [], beforeDiffLines = [], priorRound = null, contextBundle = null,
66
66
  }) {
67
67
  const t = task || {};
68
68
  const desc = (t.description || '(no description)').trim();
@@ -106,6 +106,29 @@ function buildWorkerPrompt({
106
106
  lines.push('');
107
107
  lines.push(readFilesHint);
108
108
  lines.push('');
109
+ // task 1002660: the shared context bundle — post-change file contents read
110
+ // ONCE by the caller so four workers don't each re-explore the repo (the
111
+ // dominant panel cost). Untrusted builder-authored content → fenced, one
112
+ // block per file. The trusted lead line tells the worker Read is only
113
+ // needed for files NOT bundled.
114
+ if (contextBundle && Array.isArray(contextBundle.files) && contextBundle.files.length > 0) {
115
+ lines.push('---');
116
+ lines.push(`## Post-change file contents (${contextBundle.files.length} of ${(changedFiles || []).length} changed files, bundled)`);
117
+ lines.push('');
118
+ lines.push('The post-change contents of the changed files below are included inline — use your Read tool ONLY for files not bundled here (listed after the blocks) or for unchanged context files.');
119
+ lines.push('');
120
+ for (const f of contextBundle.files) {
121
+ // The filename is builder-controlled — scrub it like every other
122
+ // untrusted string that lands OUTSIDE a fence (panel hacker finding).
123
+ const label = `FILE: ${scrubUntrusted(String(f.file))}${f.truncated ? ' (truncated)' : ''}`;
124
+ lines.push(...fenceBlock(label, f.content));
125
+ lines.push('');
126
+ }
127
+ if (Array.isArray(contextBundle.skipped) && contextBundle.skipped.length > 0) {
128
+ lines.push(`Not bundled (deleted, binary, or over the size cap — Read them if needed): ${contextBundle.skipped.map((s) => scrubUntrusted(String(s))).join(', ')}`);
129
+ lines.push('');
130
+ }
131
+ }
109
132
  // task 1002662: prior panel round context for a delta-aware re-grade. The
110
133
  // findings/response/interim summary are builder-adjacent data → fenced; the
111
134
  // policy that governs them is trusted text outside the fences.
@@ -102,6 +102,11 @@ const DEFAULT_SHOTS = V3.shots || 3;
102
102
  const ASSUMED_BUILDER_MODEL = V4.assumed_builder_model || 'claude-fable-5';
103
103
  const DEFAULT_GRADER_MODEL = V4.default_model || 'claude-sonnet-5';
104
104
  const FALLTHROUGH_CONFIDENCE = V4.fallthrough_confidence ?? 0.3;
105
+ // task 1002660: per-worker model tier overrides (rubric-configured). The
106
+ // Quality worker was 41% of panel spend with 75% nit findings — its cq/economy
107
+ // judgment doesn't need the full cross-model tier. Keys are worker kinds;
108
+ // values are model ids. workerModelFor guards the cross-model invariant.
109
+ const WORKER_MODEL_TIERS = V4.worker_model_tiers || {};
105
110
  const workers = require('./grader-workers');
106
111
  // Carved into modules/grading/ (BV1.R73 / ADR 0091 §1+§4): the two deterministic
107
112
  // trust classifiers are KERNEL (KERNEL_FILES, R70) — reached through the doorway,
@@ -235,6 +240,20 @@ function selectGraderModel({ builderModel } = {}) {
235
240
  return DEFAULT_GRADER_MODEL;
236
241
  }
237
242
 
243
+ // task 1002660: resolve one worker's model — the rubric tier override when one
244
+ // exists, else the panel's cross-model pick. The override is refused (falls
245
+ // back to graderModel) when it lands on the BUILDER's own family: tiering is
246
+ // a cost lever, never a hole in the ADR 0024 cross-model guarantee.
247
+ const MODEL_FAMILY_TOKENS = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
248
+ function workerModelFor(kind, graderModel, builderModel) {
249
+ const tier = WORKER_MODEL_TIERS[kind];
250
+ if (!tier) return graderModel;
251
+ const bm = String(builderModel || ASSUMED_BUILDER_MODEL).toLowerCase();
252
+ const tierFamily = MODEL_FAMILY_TOKENS.find((t) => String(tier).toLowerCase().includes(t));
253
+ if (tierFamily && bm.includes(tierFamily)) return graderModel;
254
+ return tier;
255
+ }
256
+
238
257
  // ---------- v3: prompt builder (default-fail framing) ----------
239
258
 
240
259
  // The instance's project name, injected into the grader's identity line so a
@@ -1135,6 +1154,60 @@ function runSubagent({ prompt, opts = {} }) {
1135
1154
  });
1136
1155
  }
1137
1156
 
1157
+ // ---------- task 1002660: shared context bundle ----------
1158
+ //
1159
+ // ~90% of panel cost was fixed re-exploration: four subprocesses each spending
1160
+ // agentic Read turns re-opening the same changed files. Read them ONCE here
1161
+ // and hand every worker the post-change contents inline (fenced, untrusted),
1162
+ // so a worker's Read tool is only needed for files beyond the bundle.
1163
+ const CONTEXT_BUNDLE_MAX_FILES = 24;
1164
+ const CONTEXT_BUNDLE_MAX_TOTAL_CHARS = 120_000;
1165
+ const CONTEXT_BUNDLE_MAX_FILE_CHARS = 16_000;
1166
+ // Binary-ish extensions never worth inlining (the diff already names them).
1167
+ const CONTEXT_BUNDLE_SKIP_RE = /\.(?:png|jpe?g|gif|webp|ico|svgz|woff2?|ttf|eot|zip|gz|tgz|tar|pdf|mp[34]|wav|ogg|bin|exe|dll|node)$/i;
1168
+
1169
+ // Pure-ish (fs reads only, injectable): returns { files: [{file, content,
1170
+ // truncated}], skipped: [file...], total_chars } for the post-change contents
1171
+ // of changedFiles under cwd. Deleted/unreadable files land in skipped —
1172
+ // their absence from the bundle is visible, never silent.
1173
+ //
1174
+ // Security (panel hacker finding, this task): changedFiles is builder-
1175
+ // controlled and git commits symlinks, so a naive readFileSync would follow a
1176
+ // committed link OUT of the repo (SSH keys, session tokens) and inject the
1177
+ // target into worker prompts + the stored grade. lstat-refuse symlinks and
1178
+ // non-files, and require the resolved path to stay under cwd (containment) —
1179
+ // a refused entry lands in skipped, visible like every other exclusion.
1180
+ function buildContextBundle({ changedFiles, cwd, fsImpl = fs } = {}) {
1181
+ const files = [];
1182
+ const skipped = [];
1183
+ let total = 0;
1184
+ const root = path.resolve(cwd || process.cwd());
1185
+ for (const rel of (Array.isArray(changedFiles) ? changedFiles : [])) {
1186
+ if (files.length >= CONTEXT_BUNDLE_MAX_FILES || total >= CONTEXT_BUNDLE_MAX_TOTAL_CHARS) {
1187
+ skipped.push(rel);
1188
+ continue;
1189
+ }
1190
+ if (CONTEXT_BUNDLE_SKIP_RE.test(rel)) { skipped.push(rel); continue; }
1191
+ let content;
1192
+ try {
1193
+ const abs = path.resolve(root, rel);
1194
+ if (abs !== root && !abs.startsWith(root + path.sep)) { skipped.push(rel); continue; }
1195
+ const st = fsImpl.lstatSync(abs);
1196
+ if (!st.isFile() || st.isSymbolicLink()) { skipped.push(rel); continue; }
1197
+ content = fsImpl.readFileSync(abs, 'utf8');
1198
+ } catch (_) { skipped.push(rel); continue; } // deleted by the diff, or unreadable
1199
+ let truncated = false;
1200
+ if (content.length > CONTEXT_BUNDLE_MAX_FILE_CHARS) {
1201
+ content = content.slice(0, CONTEXT_BUNDLE_MAX_FILE_CHARS);
1202
+ truncated = true;
1203
+ }
1204
+ if (total + content.length > CONTEXT_BUNDLE_MAX_TOTAL_CHARS) { skipped.push(rel); continue; }
1205
+ total += content.length;
1206
+ files.push({ file: rel, content, truncated });
1207
+ }
1208
+ return { files, skipped, total_chars: total };
1209
+ }
1210
+
1138
1211
  // System 5 (task 1359): an OPT-IN cached path around runSubagent. When the caller
1139
1212
  // supplies a `cache` spec (from llm-cache.makeCacheSpec), a hit replays the stored
1140
1213
  // result at cost_usd:0 (so $0 flows naturally into extractCostUsd → session-cost);
@@ -1652,6 +1725,20 @@ async function gradeSubagent({ task, valueSummary, diff, changedFiles, diffStats
1652
1725
  ...(permPath ? permPath.findings : []),
1653
1726
  ];
1654
1727
 
1728
+ // task 1002660: one read of the changed files, shared by all four workers —
1729
+ // kills the per-worker re-exploration that dominated panel cost.
1730
+ const contextBundle = buildContextBundle({ changedFiles, cwd: runOpts.cwd });
1731
+ // task 1002660: cache-wrapped spawns. Same prompt + model replays at $0
1732
+ // (llm-cache, System 5) — the prompt embeds the diff + bundle, so the key
1733
+ // needs no separate scope sha. Absent llm-cache → plain runSubagent.
1734
+ const runSubagentPanelCached = ({ prompt, opts }) => runSubagentCached({
1735
+ prompt,
1736
+ opts,
1737
+ cache: api.llmCache
1738
+ ? api.llmCache.makeCacheSpec({ prompt, model: opts?.model, task_type: 'grade_worker' })
1739
+ : null,
1740
+ });
1741
+
1655
1742
  const startedAt = Date.now();
1656
1743
  const workerPromises = panel.map((worker) => workers.runWorker(worker, {
1657
1744
  task,
@@ -1659,12 +1746,14 @@ async function gradeSubagent({ task, valueSummary, diff, changedFiles, diffStats
1659
1746
  diff,
1660
1747
  changedFiles,
1661
1748
  diffStats,
1662
- model: graderModel,
1749
+ // task 1002660: rubric tier override per worker (cross-model-guarded).
1750
+ model: workerModelFor(worker.KIND, graderModel, builderModel),
1663
1751
  runOpts,
1664
- runSubagentFn: runSubagent,
1752
+ runSubagentFn: runSubagentPanelCached,
1665
1753
  extractAssistantResultFn: extractAssistantResult,
1666
1754
  deterministicFindings: deterministicFindings.length ? deterministicFindings : null,
1667
1755
  priorRound: priorRound || null, // task 1002662: delta-aware re-grade context
1756
+ contextBundle, // task 1002660: post-change file contents, read once
1668
1757
  }));
1669
1758
  const workerResults = await Promise.all(workerPromises);
1670
1759
  // Append the deterministic checks as pseudo-workers so the any-fail gate
@@ -1906,6 +1995,8 @@ module.exports = {
1906
1995
  // v3 surface (kept exported for diagnostics + tests)
1907
1996
  buildPrompt,
1908
1997
  selectGraderModel,
1998
+ workerModelFor, // task 1002660: per-worker tier override (cross-model-guarded)
1999
+ buildContextBundle, // task 1002660: shared post-change file contents, read once per panel
1909
2000
  validateAndScore,
1910
2001
  aggregateShots,
1911
2002
  runSubagent,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.48",
3
+ "version": "1.19.50",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.48",
9
+ "version": "1.19.50",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.48",
3
+ "version": "1.19.50",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -33,7 +33,7 @@ function npmrcContent({ scope = CORE_SCOPE, host = REGISTRY_HOST, tokenEnv = TOK
33
33
  return [
34
34
  `; Cloud Bongos — env-fed auth for the PRIVATE ${scope}/* packages (ADR 0134, task 2090).`,
35
35
  `; The token EXPANDS FROM THE ENVIRONMENT at install time; nothing secret is committed here.`,
36
- `; \`npm ci\` needs ${tokenEnv} set (a read-only automation token from the paid \`bongos\` org);`,
36
+ `; \`npm ci\` needs ${tokenEnv} set (a read-only npm access token from the paid \`bongos\` org);`,
37
37
  `; without it the private package 404s — that is the expected tokenless outcome.`,
38
38
  `${scope}:registry=https://${host}/`,
39
39
  `//${host}/:_authToken=\${${tokenEnv}}`,
@@ -56,7 +56,7 @@
56
56
  // # staged-fresh (a re-run); it can't pre-approve.
57
57
 
58
58
  const crypto = require('node:crypto');
59
- const { execSync, execFileSync, spawnSync } = require('node:child_process');
59
+ const { execSync, execFileSync, spawnSync, spawn } = require('node:child_process');
60
60
  const fs = require('node:fs');
61
61
  const os = require('node:os');
62
62
  const path = require('node:path');
@@ -2066,6 +2066,28 @@ function shouldReuseGrade(storedGrade, currentDiffHash) {
2066
2066
  return storedGrade.diff_hash === currentDiffHash;
2067
2067
  }
2068
2068
 
2069
+ // task 1002660: task_grades has no issues column — the panel's deduped
2070
+ // issues[] survive only inside signals.workers[].issues. Rebuild them (same
2071
+ // (file, summary) dedupe as aggregatePanel) so the reuse re-POST carries the
2072
+ // substance the original PASS carried. Before this, the reused body posted
2073
+ // issues: undefined, so every reused PASS with findings read as a zero-issue
2074
+ // pass server-side and tripped the SR-9 plausibility guard into
2075
+ // manual-confirm — 0/106 successful uses, the feature defeated itself.
2076
+ function reconstructPanelIssues(signals) {
2077
+ const out = [];
2078
+ const seen = new Set();
2079
+ for (const w of (Array.isArray(signals?.workers) ? signals.workers : [])) {
2080
+ for (const it of (Array.isArray(w?.issues) ? w.issues : [])) {
2081
+ if (!it || typeof it !== 'object') continue;
2082
+ const key = `${it.file || ''}|${it.summary || ''}`;
2083
+ if (seen.has(key)) continue;
2084
+ seen.add(key);
2085
+ out.push(it);
2086
+ }
2087
+ }
2088
+ return out;
2089
+ }
2090
+
2069
2091
  // --- Subagent grader runner ---
2070
2092
  // Wraps grader.gradeSubagent so ship.js callers see a single function with
2071
2093
  // progress logging. MAS Phase 2.0 #410: gradeSubagent now runs the WORKER
@@ -2118,6 +2140,15 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
2118
2140
  builderModel, // task 1002659: real session model → cross-model grader pick
2119
2141
  runOpts,
2120
2142
  });
2143
+ // task 1002660: the single generalist shot has no Hacker or Efficiency
2144
+ // lens — acceptable for genuinely inert copy/docs files, but never
2145
+ // silently. The marker makes the dropped lenses queryable by /grade-audit
2146
+ // (same pattern as implausible_pass_waived, ADR 0164).
2147
+ result.signals = {
2148
+ ...(result.signals || {}),
2149
+ trivial_path: true,
2150
+ lenses_dropped: ['hacker', 'efficiency'],
2151
+ };
2121
2152
  } else {
2122
2153
  const panel = grader.WORKERS.DEFAULT_PANEL;
2123
2154
  console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
@@ -2160,6 +2191,21 @@ function runSmoke() {
2160
2191
  return { ran: true, clean: okPms };
2161
2192
  }
2162
2193
 
2194
+ // task 1002660: the async twin of runSmoke for the PRE-RESOLVE gate only. The
2195
+ // sync runSmoke blocks the event loop (execSync), which would freeze the
2196
+ // overlapped grade-panel promise mid-flight; awaiting this one keeps the loop
2197
+ // free so the panel's subprocess callbacks progress while smoke runs. The
2198
+ // post-merge smoke call keeps the sync form (nothing overlaps there).
2199
+ function runSmokeAsync() {
2200
+ return new Promise((resolve) => {
2201
+ const child = spawn(process.execPath, ['scripts/gds/smoke-gds.js'], {
2202
+ cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
2203
+ });
2204
+ child.on('error', () => resolve({ ran: true, clean: false }));
2205
+ child.on('close', (code) => resolve({ ran: true, clean: code === 0 }));
2206
+ });
2207
+ }
2208
+
2163
2209
  // task 1002605 (G-18): the pre-resolve smoke gate's two message sets, pure so the
2164
2210
  // wording is pinned by a DB-free unit test (tests/ship_smoke_gate.mjs). A smoke
2165
2211
  // FAIL used to fall silently through into the claim resolve — claim consumed,
@@ -2827,7 +2873,11 @@ function buildPriorRoundContext({ storedGrade, responseText = '', interimCommits
2827
2873
  // and check whether a stored PASS grade exists for the same hash. On a match
2828
2874
  // we re-POST the stored grade (marking it reused in signals) and skip the
2829
2875
  // panel entirely — cheap re-ships with an unchanged diff stay fast.
2830
- async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null }) {
2876
+ // task 1002660: the COMPUTE half — the reuse lookup + the panel run. No server
2877
+ // writes, so shipMain can start it before the smoke gate and overlap the
2878
+ // multi-minute panel with smoke + the claim resolve. Returns
2879
+ // { kind: 'reuse', currentHash, reusedResult } | { kind: 'fresh', currentHash, gradeResult }.
2880
+ async function computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound = null }) {
2831
2881
  // Compute the fingerprint of the raw (untruncated) diff so we can compare
2832
2882
  // against any stored grade, independent of the display-truncation cap.
2833
2883
  const currentHash = computeRawDiffHash(baseline);
@@ -2838,7 +2888,7 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
2838
2888
 
2839
2889
  if (shouldReuseGrade(storedGrade, currentHash)) {
2840
2890
  console.log(' Grade reuse: stored PASS grade matches current diff hash — skipping panel.');
2841
- // Re-POST the stored result with a reused marker so the audit trail is
2891
+ // Reuse the stored result with a reused marker so the audit trail is
2842
2892
  // complete. We keep the original grader_kind and inject reuse metadata
2843
2893
  // into signals so a bare table scan can spot reused grades.
2844
2894
  const reusedSignals = {
@@ -2855,10 +2905,12 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
2855
2905
  rubric_scores: storedGrade.rubric_scores,
2856
2906
  notes_md: storedGrade.notes_md,
2857
2907
  signals: reusedSignals,
2908
+ // task 1002660: carry the original panel's issues (rebuilt from
2909
+ // signals.workers[].issues — the row stores them nowhere else) so the
2910
+ // reused PASS stands on the same substance/evidence it passed with.
2911
+ issues: reconstructPanelIssues(storedGrade.signals),
2858
2912
  };
2859
- const payload = { ...buildGradePayload(reusedResult, committedFiles), diff_hash: currentHash };
2860
- return (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2861
- // No logGraderPanelCost — the panel didn't run, there's no cost to log.
2913
+ return { kind: 'reuse', currentHash, reusedResult };
2862
2914
  }
2863
2915
  }
2864
2916
 
@@ -2875,7 +2927,25 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
2875
2927
  if (priorRound?.audit) {
2876
2928
  gradeResult.signals = { ...(gradeResult.signals || {}), regrade: priorRound.audit };
2877
2929
  }
2878
- const payload = { ...buildGradePayload(gradeResult, committedFiles), diff_hash: currentHash ?? undefined };
2930
+ return { kind: 'fresh', currentHash, gradeResult };
2931
+ }
2932
+
2933
+ // The RECORD half: POST the computed grade + log the panel cost. `precomputed`
2934
+ // (a promise from computeGradeResult, task 1002660) lets the ship path hand in
2935
+ // the overlapped computation; the --regrade path passes nothing and computes
2936
+ // inline — behavior identical to the pre-split gradeAndRecord either way.
2937
+ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null, precomputed = null }) {
2938
+ const computed = await (precomputed
2939
+ || computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound }));
2940
+
2941
+ if (computed.kind === 'reuse') {
2942
+ const payload = { ...buildGradePayload(computed.reusedResult, committedFiles), diff_hash: computed.currentHash };
2943
+ return (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2944
+ // No logGraderPanelCost — the panel didn't run, there's no cost to log.
2945
+ }
2946
+
2947
+ const { gradeResult } = computed;
2948
+ const payload = { ...buildGradePayload(gradeResult, committedFiles), diff_hash: computed.currentHash ?? undefined };
2879
2949
  const gradePost = await (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2880
2950
  // Log the panel's cost once the grade is recorded — independent of the
2881
2951
  // pass/fail VERDICT (the panel ran, and cost money, either way), but only
@@ -3844,8 +3914,30 @@ async function shipMain() {
3844
3914
  // on the actual ship: the --approved re-run, or a laptop / non-game ship the
3845
3915
  // gate waved straight through. (#1061 — the gate is a no-op for those, so this
3846
3916
  // is the same single smoke run as before, just sequenced after it.)
3917
+
3918
+ // task 1002660: start the grade COMPUTATION now, so the multi-minute panel
3919
+ // overlaps the smoke run + claim resolve instead of serializing behind them.
3920
+ // computeGradeResult performs no server writes — the grade POST stays in the
3921
+ // grade step below, after the resolve lands the task at 'completed'. On a
3922
+ // smoke abort the in-flight panel is discarded (its partial spend is lost —
3923
+ // rare, and cheaper than serializing every ship behind smoke).
3924
+ let gradeCompute = null;
3925
+ let fullTaskForGrade = null;
3926
+ if (!skipGrade) {
3927
+ const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
3928
+ fullTaskForGrade = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
3929
+ console.log('Starting the quality grader panel (overlaps the smoke run — task 1002660)...');
3930
+ gradeCompute = computeGradeResult({
3931
+ taskId, task: fullTaskForGrade, summary,
3932
+ baseline: graderBaseline, committedFiles,
3933
+ });
3934
+ gradeCompute.catch(() => {}); // an abort path must not die on an unhandled rejection
3935
+ }
3936
+
3847
3937
  console.log('Running smoke tests against production...');
3848
- const smoke = runSmoke();
3938
+ // task 1002660: the async twin — awaiting keeps the event loop free so the
3939
+ // overlapped panel's subprocess callbacks progress while smoke runs.
3940
+ const smoke = await runSmokeAsync();
3849
3941
  const passed = smoke.clean;
3850
3942
  console.log('');
3851
3943
  console.log(`Verification: smoke=${smoke.clean ? 'clean' : 'FAIL'}, overall=${passed ? 'PASS' : 'FAIL'}`);
@@ -3977,24 +4069,22 @@ async function shipMain() {
3977
4069
  console.log('');
3978
4070
  console.log('Running subagent quality grader...');
3979
4071
 
3980
- // Fetch the full task (description, kind, discipline, etc.) so the
3981
- // grader prompt has the intent the subagent is grading against.
3982
- const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
3983
- const fullTask = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
3984
-
3985
4072
  // #513 + #536: the grader reads COMMITTED history (baseline...HEAD), not
3986
4073
  // the working tree. The empty-committed-diff guard now runs earlier — BEFORE
3987
4074
  // the claim is resolved (see above) — so by the time we reach here the diff
3988
4075
  // is guaranteed non-empty on the grader path. Reuse the baseline + file list
3989
- // computed there so we don't diff twice. #678: gradeAndRecord runs the
3990
- // subagent grader, POSTs the result, and logs the panel cost — the same unit
3991
- // the --regrade path reuses.
4076
+ // computed there so we don't diff twice. #678: gradeAndRecord POSTs the
4077
+ // result and logs the panel cost — the same unit the --regrade path reuses.
4078
+ // task 1002660: the panel computation was started BEFORE the smoke gate
4079
+ // (gradeCompute above) — hand it in so this step only waits out whatever
4080
+ // panel time smoke + resolve didn't already absorb.
3992
4081
  const gradePost = await gradeAndRecord({
3993
4082
  taskId,
3994
- task: fullTask,
4083
+ task: fullTaskForGrade || { id: taskId, title: ac.title },
3995
4084
  summary,
3996
4085
  baseline: graderBaseline,
3997
4086
  committedFiles,
4087
+ precomputed: gradeCompute,
3998
4088
  });
3999
4089
 
4000
4090
  if (!gradePost.ok) {
@@ -4281,7 +4371,8 @@ module.exports = {
4281
4371
  // V4.R08: diff fingerprint + grade-reuse helpers — exported pure so the
4282
4372
  // truth table (same hash+pass→reuse, fail→no reuse, mismatch→re-grade,
4283
4373
  // null→re-grade) is unit-tested without git/network.
4284
- diffHash, computeRawDiffHash, shouldReuseGrade, gradeAndRecord,
4374
+ diffHash, computeRawDiffHash, shouldReuseGrade, reconstructPanelIssues, gradeAndRecord,
4375
+ computeGradeResult, // task 1002660: the no-server-write compute half, overlapped with smoke
4285
4376
  // task 1002662: delta-aware re-grade — the prior-round payload builder,
4286
4377
  // exported pure so the panel/audit shapes are pinned without git/network.
4287
4378
  buildPriorRoundContext,
package/src/module-api.js CHANGED
@@ -523,8 +523,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
523
523
  // landed since 1.19.46 with no explicit bump. run 31521404176. (task 1002620)
524
524
  // 1.19.48 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
525
525
  // landed since 1.19.47 with no explicit bump. run 31522865111. (task 1002620)
526
+ // 1.19.49 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
527
+ // landed since 1.19.48 with no explicit bump. run 31523559509. (task 1002620)
528
+ // 1.19.50 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
529
+ // landed since 1.19.49 with no explicit bump. run 31524280584. (task 1002620)
526
530
  // ---------------------------------------------------------------------------
527
- const CORE_VERSION = '1.19.48'; // CI auto-patch carrier (ADR 0161); see changelog above
531
+ const CORE_VERSION = '1.19.50'; // CI auto-patch carrier (ADR 0161); see changelog above
528
532
 
529
533
  // A namespaced logger so a module's log lines are attributable + consistent.
530
534
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -15,7 +15,7 @@ import { createRequire } from 'node:module';
15
15
  import { makeSqlAwareClient } from './helpers.mjs';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
- const { diffHash, shouldReuseGrade } = require('../scripts/gds/ship.js');
18
+ const { diffHash, shouldReuseGrade, reconstructPanelIssues } = require('../scripts/gds/ship.js');
19
19
  const db = require('../modules/lifecycle/db.js');
20
20
  // applyGrade resolves the 'reward' kernel port; register it so calls in the
21
21
  // passed=true path don't blow up on null (mirrors grade_attribution.mjs).
@@ -111,6 +111,34 @@ test('shouldReuseGrade → false when currentDiffHash is null (no baseline)', ()
111
111
  assert.equal(shouldReuseGrade({ passed: true, diff_hash: HASH_A }, null), false);
112
112
  });
113
113
 
114
+ // ======================================================================
115
+ // 2b. reconstructPanelIssues — the reuse re-POST carries the original
116
+ // panel's issues, rebuilt from signals.workers[].issues (task 1002660)
117
+ // ======================================================================
118
+
119
+ test('reconstructPanelIssues rebuilds deduped issues from workers', () => {
120
+ const issues = reconstructPanelIssues({
121
+ workers: [
122
+ { kind: 'narc', issues: [{ severity: 'nit', file: 'a.js', summary: 'style' }] },
123
+ { kind: 'quality', issues: [
124
+ { severity: 'nit', file: 'a.js', summary: 'style' }, // dupe of narc's
125
+ { severity: 'minor', file: 'b.js', summary: 'dead code' },
126
+ ] },
127
+ { kind: 'hacker', issues: [] },
128
+ { kind: 'efficiency' }, // no issues field
129
+ ],
130
+ });
131
+ assert.equal(issues.length, 2, 'dupes collapse on (file, summary)');
132
+ assert.deepEqual(issues.map((i) => i.file), ['a.js', 'b.js']);
133
+ });
134
+
135
+ test('reconstructPanelIssues → [] on missing/empty/junk signals', () => {
136
+ assert.deepEqual(reconstructPanelIssues(null), []);
137
+ assert.deepEqual(reconstructPanelIssues({}), []);
138
+ assert.deepEqual(reconstructPanelIssues({ workers: 'junk' }), []);
139
+ assert.deepEqual(reconstructPanelIssues({ workers: [null, { issues: [null, 'junk'] }] }), []);
140
+ });
141
+
114
142
  // ======================================================================
115
143
  // 3. db.applyGrade stores diff_hash (SQL-aware fake pool, NODE_ENV=test)
116
144
  // ======================================================================
package/tests/grader.mjs CHANGED
@@ -840,6 +840,128 @@ test('selectGraderModel: builder Haiku → grader Opus', () => {
840
840
  assert.equal(grader.selectGraderModel({ builderModel: 'haiku' }), 'claude-opus-5');
841
841
  });
842
842
 
843
+ // --- task 1002660: per-worker model tiering (cross-model-guarded) ---
844
+
845
+ test('workerModelFor: Quality tiers down to haiku; other workers keep the panel model', () => {
846
+ assert.equal(
847
+ grader.workerModelFor('quality', 'claude-sonnet-5', 'claude-fable-5'),
848
+ 'claude-haiku-4-5-20251001'
849
+ );
850
+ for (const kind of ['narc', 'hacker', 'efficiency']) {
851
+ assert.equal(grader.workerModelFor(kind, 'claude-sonnet-5', 'claude-fable-5'), 'claude-sonnet-5');
852
+ }
853
+ });
854
+
855
+ test('workerModelFor: a tier override landing on the BUILDER family is refused (cross-model guarantee)', () => {
856
+ // A haiku builder must not be quality-judged by haiku — fall back to the panel model.
857
+ assert.equal(
858
+ grader.workerModelFor('quality', 'claude-opus-5', 'claude-haiku-4-5-20251001'),
859
+ 'claude-opus-5'
860
+ );
861
+ });
862
+
863
+ // --- task 1002660: shared context bundle ---
864
+
865
+ const plainFileStat = { isFile: () => true, isSymbolicLink: () => false };
866
+ function fakeFsFor(files, { symlinks = [] } = {}) {
867
+ const relOf = (p) => Object.keys(files).find((k) => String(p).replace(/\\/g, '/').endsWith(k));
868
+ return {
869
+ lstatSync: (p) => {
870
+ const rel = relOf(p);
871
+ if (!rel) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
872
+ return symlinks.includes(rel)
873
+ ? { isFile: () => false, isSymbolicLink: () => true }
874
+ : plainFileStat;
875
+ },
876
+ readFileSync: (p) => {
877
+ const rel = relOf(p);
878
+ if (!rel) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
879
+ return files[rel];
880
+ },
881
+ };
882
+ }
883
+
884
+ test('buildContextBundle reads changed files once, skips binaries/missing, respects caps', () => {
885
+ const files = {
886
+ 'src/a.js': 'const a = 1;\n',
887
+ 'docs/b.md': '# b\n',
888
+ 'art/pic.png': 'binarybytes',
889
+ };
890
+ const bundle = grader.buildContextBundle({
891
+ changedFiles: ['src/a.js', 'docs/b.md', 'art/pic.png', 'gone/deleted.js'],
892
+ cwd: '/repo',
893
+ fsImpl: fakeFsFor(files),
894
+ });
895
+ assert.deepEqual(bundle.files.map((f) => f.file), ['src/a.js', 'docs/b.md']);
896
+ assert.deepEqual(bundle.skipped, ['art/pic.png', 'gone/deleted.js'], 'binary + deleted → skipped, visibly');
897
+ assert.equal(bundle.files[0].content, 'const a = 1;\n');
898
+ assert.equal(bundle.total_chars, files['src/a.js'].length + files['docs/b.md'].length);
899
+ });
900
+
901
+ test('buildContextBundle truncates oversized files and marks them', () => {
902
+ const big = 'x'.repeat(20_000);
903
+ const bundle = grader.buildContextBundle({
904
+ changedFiles: ['big.js'], cwd: '/r',
905
+ fsImpl: { lstatSync: () => plainFileStat, readFileSync: () => big },
906
+ });
907
+ assert.equal(bundle.files[0].truncated, true);
908
+ assert.ok(bundle.files[0].content.length <= 16_000);
909
+ });
910
+
911
+ test('buildContextBundle REFUSES symlinks — a committed link cannot pull out-of-repo content into prompts (hacker finding)', () => {
912
+ const files = { 'src/a.js': 'ok', 'evil-link.js': 'should never be read' };
913
+ const bundle = grader.buildContextBundle({
914
+ changedFiles: ['src/a.js', 'evil-link.js'],
915
+ cwd: '/repo',
916
+ fsImpl: fakeFsFor(files, { symlinks: ['evil-link.js'] }),
917
+ });
918
+ assert.deepEqual(bundle.files.map((f) => f.file), ['src/a.js']);
919
+ assert.ok(bundle.skipped.includes('evil-link.js'), 'symlink refused, visibly');
920
+ });
921
+
922
+ test('buildContextBundle REFUSES path escapes — a ../ entry cannot leave cwd (containment)', () => {
923
+ let readAttempted = false;
924
+ const bundle = grader.buildContextBundle({
925
+ changedFiles: ['../outside/secrets.txt'],
926
+ cwd: '/repo/inner',
927
+ fsImpl: {
928
+ lstatSync: () => plainFileStat,
929
+ readFileSync: () => { readAttempted = true; return 'secret'; },
930
+ },
931
+ });
932
+ assert.deepEqual(bundle.files, []);
933
+ assert.deepEqual(bundle.skipped, ['../outside/secrets.txt']);
934
+ assert.equal(readAttempted, false, 'the escape is refused before any read');
935
+ });
936
+
937
+ test('worker prompts render the context bundle fenced, with the skipped list visible', () => {
938
+ const prompt = grader.WORKERS.narc.buildPrompt({
939
+ task: { id: 1, title: 't', description: 'd' },
940
+ valueSummary: 'v',
941
+ diff: 'diff --git a/x b/x',
942
+ changedFiles: ['src/a.js', 'art/pic.png'],
943
+ contextBundle: {
944
+ files: [{ file: 'src/a.js', content: 'const a = 1;', truncated: false }],
945
+ skipped: ['art/pic.png'],
946
+ total_chars: 12,
947
+ },
948
+ });
949
+ assert.ok(prompt.includes('Post-change file contents'), 'bundle section renders');
950
+ assert.ok(prompt.includes('const a = 1;'), 'bundled content is present');
951
+ assert.ok(prompt.includes('FILE: src/a.js'), 'per-file fence label present');
952
+ assert.ok(/Not bundled .*art\/pic\.png/.test(prompt), 'skipped files are named, never silent');
953
+ });
954
+
955
+ test('worker prompts without a bundle are unchanged (no bundle section)', () => {
956
+ const prompt = grader.WORKERS.narc.buildPrompt({
957
+ task: { id: 1, title: 't', description: 'd' },
958
+ valueSummary: 'v',
959
+ diff: 'diff --git a/x b/x',
960
+ changedFiles: ['src/a.js'],
961
+ });
962
+ assert.ok(!prompt.includes('Post-change file contents'));
963
+ });
964
+
843
965
  test('selectGraderModel: missing builder → default (assumed-Fable builder → Sonnet 5); unknown id FALLS BACK, never throws (task 1002659)', () => {
844
966
  // No builderModel is the legitimate default path (the caller assumes the
845
967
  // owner-top-model builder per CLAUDE.md §2), resolving to the default grader.