@bongos/core 1.21.84 → 1.21.86

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.21.84",
6
- "core_contract": "1.21.84",
7
- "source_commit": "77cd95ff8cbddfb714a4927414a4d980720ac2ca",
5
+ "core_version": "1.21.86",
6
+ "core_contract": "1.21.86",
7
+ "source_commit": "7183f6e501a4de2ea256ffa4c2d19ccc62d7fdc1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T02:56:20.018Z",
9
+ "built_at": "2026-10-09T04:48:08.126Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 590,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 2996,
14
+ "functional_verbatim": 2998,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3615,
20
- "tree_sha256": "e5d1d484866739a4ebb4545ddf68af311452e34b73ba4d6a81353e0497ca0ad3",
19
+ "file_count": 3617,
20
+ "tree_sha256": "391c304f1f86515a2c812e6764e83ba98b5fe6a67757909baf15d8e88009694b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2327,7 +2327,7 @@
2327
2327
  {
2328
2328
  "path": "docs/architecture.md",
2329
2329
  "mode": "0000644",
2330
- "sha256": "5236bf63df238c4b93e515a9982e76e0908382d049bf38035b5bda2a3ceda11b"
2330
+ "sha256": "0dac6b0ee55d7836cf6352c695f592000bfd61d0c3ec06d9c17d37b476b1134a"
2331
2331
  },
2332
2332
  {
2333
2333
  "path": "docs/branding-contract.md",
@@ -2862,7 +2862,7 @@
2862
2862
  {
2863
2863
  "path": "docs/module-api-changelog.md",
2864
2864
  "mode": "0000644",
2865
- "sha256": "295cb4db0fe62dc0ac7a45eda7008205e5d1ee79b593e2a240232ed1a7d2d8db"
2865
+ "sha256": "007f5683bb299be37f78f220958ae14bb8a8069fc59b81cc6fb94a12c5b3712f"
2866
2866
  },
2867
2867
  {
2868
2868
  "path": "docs/modules-contract.md",
@@ -7202,7 +7202,7 @@
7202
7202
  {
7203
7203
  "path": "modules/lifecycle/page-tweak-hold.js",
7204
7204
  "mode": "0000644",
7205
- "sha256": "3938cb60e74e910c57ef84384bfd0363c0700e0368dbea98ee880c7b9fe60aeb"
7205
+ "sha256": "54703e1d2814402218057c65691e5fd1f11572eb9e667c1e0148ecb5cc9efe80"
7206
7206
  },
7207
7207
  {
7208
7208
  "path": "modules/lifecycle/page-tweak-reads.js",
@@ -7377,7 +7377,7 @@
7377
7377
  {
7378
7378
  "path": "modules/lifecycle/routes/tasks.js",
7379
7379
  "mode": "0000644",
7380
- "sha256": "4019166678703aee51179d0b92a5aaefa728a5f992b7b472703e61c1e55c30f7"
7380
+ "sha256": "efd442d161d9b212d1e9aa4cad06aa428b1e4c74ac67d22658591ca7c28f2621"
7381
7381
  },
7382
7382
  {
7383
7383
  "path": "modules/lifecycle/routes/version-route-authz.js",
@@ -7402,7 +7402,7 @@
7402
7402
  {
7403
7403
  "path": "modules/lifecycle/ship-card.js",
7404
7404
  "mode": "0000644",
7405
- "sha256": "1935e6f0a3fb9089c2aacd9bbdf1ff1296487065c8c5a25beaf96306d88a9f5b"
7405
+ "sha256": "fc803defc468a0e625d82f305d00118ec77bb63d74d7b75b4d3ba03bdd16659d"
7406
7406
  },
7407
7407
  {
7408
7408
  "path": "modules/lifecycle/ship-files.js",
@@ -10097,12 +10097,12 @@
10097
10097
  {
10098
10098
  "path": "package-lock.json",
10099
10099
  "mode": "0000644",
10100
- "sha256": "24e60fc4fd189a4e9d735e54e99b84734ff1a656551665196974d9355cbedfc3"
10100
+ "sha256": "9d3ecc5f558e51dc8e4e66a6f3f0a6e9773e517ce9571ae4ec130b33128536ff"
10101
10101
  },
10102
10102
  {
10103
10103
  "path": "package.json",
10104
10104
  "mode": "0000644",
10105
- "sha256": "4be21978dcd4470cf27cd34d9f788325adb8e1c1c57a437c6a3abdc94bda9448"
10105
+ "sha256": "ce90ad007650b7bcf0705bf0743acf66507496e0a043d7d6c2d9b84795b94f9b"
10106
10106
  },
10107
10107
  {
10108
10108
  "path": "public-docs/index.html",
@@ -10122,7 +10122,7 @@
10122
10122
  {
10123
10123
  "path": "release-notes.json",
10124
10124
  "mode": "0000644",
10125
- "sha256": "f214849f7282c7218740a89c5ad9e3e464bdf6913043eefd4d104e55f9439898"
10125
+ "sha256": "99cc3e08c1384339c868c499061c081eade1bcb0a996c47d3e644066d22ca536"
10126
10126
  },
10127
10127
  {
10128
10128
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11582,12 +11582,12 @@
11582
11582
  {
11583
11583
  "path": "scripts/gds/ship-finish.js",
11584
11584
  "mode": "0000644",
11585
- "sha256": "8635a70645640302d420974c85fce6578a4e6b26393aff57b3620808771d5b4c"
11585
+ "sha256": "23856ecf2ed0224fcc69fa4e67413d2248462813896e3b832a20f3a2d0a886e6"
11586
11586
  },
11587
11587
  {
11588
11588
  "path": "scripts/gds/ship-flow.js",
11589
11589
  "mode": "0000644",
11590
- "sha256": "cdd4e7a24b0fd3418c911e331635483db16a18b6e0326401bc54806da83f8174"
11590
+ "sha256": "d114ae690d86270db76770fc3531a5530d1ab64a031a2528368864f4b330ac56"
11591
11591
  },
11592
11592
  {
11593
11593
  "path": "scripts/gds/ship-git.js",
@@ -11597,7 +11597,7 @@
11597
11597
  {
11598
11598
  "path": "scripts/gds/ship-grade-steps.js",
11599
11599
  "mode": "0000644",
11600
- "sha256": "89025445be960efd1faedbea57dc6ffdef42bdd637e47409d42671eb3744ffd1"
11600
+ "sha256": "4a0187643d8e0d74b0199f913c24fbd14acf035c27d6040d4ecdfcf905732398"
11601
11601
  },
11602
11602
  {
11603
11603
  "path": "scripts/gds/ship-grade.js",
@@ -11617,7 +11617,7 @@
11617
11617
  {
11618
11618
  "path": "scripts/gds/ship-land.js",
11619
11619
  "mode": "0000644",
11620
- "sha256": "1dc660f9eff1e93b53567cb3406ad6aa0bc80768e93ec905031a5e5d7d37dceb"
11620
+ "sha256": "e518039d82343b5ff3bb21e1a6e0a5a8e0b9368d05b791be4910d76ea075378e"
11621
11621
  },
11622
11622
  {
11623
11623
  "path": "scripts/gds/ship-memory-sync.js",
@@ -12137,7 +12137,7 @@
12137
12137
  {
12138
12138
  "path": "src/bongos/module-store.js",
12139
12139
  "mode": "0000644",
12140
- "sha256": "373a0ff03c6644ea622ff859edfe9003fcfa94ea7eb5ca937059865c3eeb317f"
12140
+ "sha256": "c45fffd2d1320cfadd2177421e8b58643676a71fa1bac7a01e4219c60bfe20b1"
12141
12141
  },
12142
12142
  {
12143
12143
  "path": "src/bongos/module-submissions.js",
@@ -12292,7 +12292,7 @@
12292
12292
  {
12293
12293
  "path": "src/bongos/routes/modules.js",
12294
12294
  "mode": "0000644",
12295
- "sha256": "469a276bc6293c5148288021775d9d5ceca6b81e452533f02e36fbd9e71702dd"
12295
+ "sha256": "aea0a0f28621500bee51e4fec8e794139cf90c3360fdc55caecbbd5a5fc82b69"
12296
12296
  },
12297
12297
  {
12298
12298
  "path": "src/bongos/routes/my-sessions.js",
@@ -12372,7 +12372,7 @@
12372
12372
  {
12373
12373
  "path": "src/module-api.js",
12374
12374
  "mode": "0000644",
12375
- "sha256": "91e35f3a22e967483fc2c4a6390b61839c88d0cceae5acf54e5417315158f1d2"
12375
+ "sha256": "2fd52ee0afe6fc9ade992a558ebb0fc3ad5904f876f7c5093dd60ea5d201b448"
12376
12376
  },
12377
12377
  {
12378
12378
  "path": "src/module-loader/catalog.js",
@@ -14874,6 +14874,11 @@
14874
14874
  "mode": "0000644",
14875
14875
  "sha256": "ca7fbfcca238b4ffed576ca1b3fca5d848f4d9d3eab8321353325d54ff505810"
14876
14876
  },
14877
+ {
14878
+ "path": "tests/held_tweak_reporting.mjs",
14879
+ "mode": "0000644",
14880
+ "sha256": "f58a95e019d9e5dd22c3fa2321734cbb7c323b356184198e7d3cb4507ee3d04a"
14881
+ },
14877
14882
  {
14878
14883
  "path": "tests/help_requests.mjs",
14879
14884
  "mode": "0000644",
@@ -15749,6 +15754,11 @@
15749
15754
  "mode": "0000644",
15750
15755
  "sha256": "43828c1a95652180d558e7676cc2053d0599a61c0edf4cd1ab100820fc7de493"
15751
15756
  },
15757
+ {
15758
+ "path": "tests/module_store_instance_root.mjs",
15759
+ "mode": "0000644",
15760
+ "sha256": "aa66cdb054eeaef8c3c8babf5238f029d13b668a45a97230efe5f8cfa8908d48"
15761
+ },
15752
15762
  {
15753
15763
  "path": "tests/module_store_listing.mjs",
15754
15764
  "mode": "0000644",
@@ -16027,7 +16037,7 @@
16027
16037
  {
16028
16038
  "path": "tests/page_tweak_hold.mjs",
16029
16039
  "mode": "0000644",
16030
- "sha256": "0ab837626d1d9f70f8dcc0037f9162dfbc596fa5b846820f2108bdd539126825"
16040
+ "sha256": "c3c3b7d44baacd33d1b42db4609dcb8312d8ba46ac4dab38abc57371a4bde74b"
16031
16041
  },
16032
16042
  {
16033
16043
  "path": "tests/paintbrush.mjs",
@@ -291,7 +291,7 @@ Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again
291
291
  - Own-scoped session self-service ([ADR 0206](adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md), task 1003341), in [`src/bongos/routes/my-sessions.js`](../src/bongos/routes/my-sessions.js): `GET /me/sessions` (every live session on the caller's own account — source, user agent, created/last-used/expires, a `current` flag; the token value is never selected), `POST /me/sessions/revoke` (`{session_id}` — one of your own; a miss answers `revoked: 0` so a stranger's id cannot be probed) and `POST /me/sessions/revoke-all` (sign out everywhere, the requesting session included). All three gate on `requireBuilder` alone and key every statement on `req.builder.id` — an account is never named in the query, body or path. They exist because a bearer refreshes to `now()+24h` on every call, so a leaked one never expires, and the cross-builder pair (`POST /auth/revoke`, `/auth/revoke-all`, both still gated on the admin `auth.session.revoke` atom) needed an Archon the victim might not have. Surfaced in the hall at Settings → Active sessions.
292
292
  - Authenticated CLI/web: `GET /me` (returns `rank`), `GET /versions[/progress]`, `GET /tasks[?version=&status=&kind=&discipline=]`, `GET /tasks/claimable[?version=&discipline=]`, `GET /tasks/:id`, `POST /tasks` (accepts `discipline` + `criterion_ids`; **metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)), `PATCH /tasks/:id` (supports `parent_task_id`, `kind`, `discipline`, `goal_id` — (re)assign the task's goal; must be a goal in the task's own version, task 1763; **metic+**), `POST /tasks/:id/promote` (**metic+**), `POST /claims`, `POST /claims/:id/resolve`, `POST /cost`.
293
293
  - Criterion rollup + task↔criterion links ([ADR 0025](adr/0025-structured-criterion-task-link.md), [#435](https://example.com/builders#/task/435)/[#438](https://example.com/builders#/task/438)): `GET /versions/:id/progress` rolls up each done-when criterion → its gating tasks (via `task_criteria`) → live status counts + the not-yet-shipped `remaining[]` + `unattributed_tasks` (the read behind the `/status` skill, in `src/bongos/done-when.js criterionProgress`). The link CRUD mirrors the dependency endpoints: `GET /tasks/:id/criteria`, `POST /tasks/:id/criteria` (**metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)) and `DELETE /tasks/:id/criteria/:criterionId` (**metic+**). A criterion ref is a numeric `done_when_criteria.id`, a positional `"Cn"` token, or a `criterion_id` slug, resolved against the task's version (`POST /tasks` links at create time so `/status` counts the task with no backfill).
294
- - Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under `var/module-store/` and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
294
+ - Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under the INSTANCE root's `var/module-store/` (`MODULE_STORE_DIR` overrides; never inside the installed core package, which every upgrade replaces, and a boot move carries files from that old path, task 1004399) and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
295
295
  - Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805). `bongos module update` (task 1003786) reuses the same three calls; the acquire route reads price only for a caller with no entitlement, so a holder is never charged again.
296
296
  - Module store delist (ADR 0338 D2, task 1003797): `POST /store/modules/:key/delist` (`requireBuilder` + `requirePermission('module.delist')`, Metic floor, granted by `government_022`; strict body `{reason}`, reason required, recorded by the audit middleware). Sets `store_modules.status='delisted'` and `delisted_at` — one-way, no relist route. Every store read already refuses a delisted key (acquire, tarball, acquired, publish), while `GET /store/modules/latest` still answers with the status so `bongos upgrade` warns the holder. `GET /modules` sets `store_delisted` on an instance-tree module the store delisted (hall pill "delisted · no more updates"), and `bongos module list` marks it DELISTED (fail-soft store check, instance modules only). Never revokes an entitlement, never reaches an installed copy, never writes a score (ADR 0359). 404 unknown key, 409 `already_delisted`.
297
297
  - Server-mediated branch publish (ADR 0055 / [#1025](https://example.com/builders#/task/1025) — lets a checkout with no GitHub push credential ship). Owner-gated like ship: `POST /tasks/:id/publish-branch` (`requireBuilder` + `gateTaskOwnership`; own 32 MB json parser for the base64 thin-bundle body, 20 MB decoded cap; the server pushes the branch + opens the PR + auto-merges with a server-side push credential — a **GitHub App** installation token (short-lived, repo-scoped; preferred, [ADR 0055](adr/0055-server-mediated-branch-publish.md) update / task 1028) or the `GITHUB_PUSH_TOKEN` PAT fallback; `503 push_unconfigured` when neither is set; `400 tip_mismatch` if the bundle tip ≠ the claimed `head_sha`) and `GET /tasks/:id/publish-status?branch=…` (polls PR + deploy-prod state derived live from GitHub). `branch` is optional: without it the server finds the task's own PR by its `task <id>: …` title, and the answer names the `branch` it used and its `branch_source` (`query` / `task_pr`, or `null` with a `branch_hint` when there is no such PR) — never a `400` (task 1003764). When the PR is still open it also carries `merge_driver`, whose `reason` says why it has not merged; on a red PR `failing_tests` names the failing unit tests (read from the `unit-report` commit status the `unit` workflow posts) and `checks_unreadable: true` says the check list is blind because the App lacks `Checks: read` — so an empty `checks[]` is never read as "nothing failed" (task 1003988, ADR 0301). Because the server's credential is the PR's AUTHOR, publish also **assigns the PR to the task's claim holder** and names them in the body (`modules/lifecycle/pr-assign.js`, task 1003991) — best-effort: the 201 carries `pr_assignee` (the login, or `null` when GitHub would not assign it) and a failed assignment never fails the publish. `ship.js` uses these in `ci` mode only when `pushVia()` resolves to server (no authenticated `gh` on the machine, or `<PREFIX>_PUSH_VIA_SERVER=1`); a machine with `gh` keeps the local push path unchanged. Implementation in `src/bongos/github-push.js`.
@@ -3000,5 +3000,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3000
3000
  landed since 1.21.82 with no explicit bump. run 37869654816. (task 1002620)
3001
3001
  1.21.84 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3002
3002
  landed since 1.21.83 with no explicit bump. run 37876887587. (task 1002620)
3003
+ 1.21.85 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3004
+ landed since 1.21.84 with no explicit bump. run 37884200093. (task 1002620)
3005
+ 1.21.86 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3006
+ landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
3003
3007
  ---------------------------------------------------------------------------
3004
3008
  ```
@@ -65,7 +65,19 @@ const HELD_NOT_ARMED = Object.freeze({
65
65
  reason: 'held for the artist (ADR 0341 D7): a page tweak lands only after its artist approves it',
66
66
  });
67
67
 
68
+ // The closing sentence of the PR body a ship opens (task 1004379). The stock
69
+ // text promised auto-merge, and a held round's PR is deliberately not armed, so
70
+ // the reader of a held PR was told the opposite of what would happen. Both PR
71
+ // writers (the server's publish route and ship.js's ci-land) end with this.
72
+ const ARMED_PR_SENTENCE = 'Auto-merge is enabled — GitHub lands it once the required checks pass.';
73
+ const HELD_PR_SENTENCE = 'Auto-merge is NOT enabled: this page tweak waits for its artist to approve it in the studio (ADR 0341 D7). It lands only after that approval.';
74
+ function prMergeSentence(held) {
75
+ return held ? HELD_PR_SENTENCE : ARMED_PR_SENTENCE;
76
+ }
77
+
68
78
  module.exports = {
79
+ HELD_PR_SENTENCE,
80
+ prMergeSentence,
69
81
  HELD_NOT_ARMED,
70
82
  HOLD_STATE,
71
83
  isPageTweak,
@@ -36,7 +36,7 @@ const { gradeQualityWhere } = require('../db-shared');
36
36
  const { helperJoinSql } = require('../page-tweak-artist'); // task 1004432: a page tweak's applier as a helper
37
37
  const auth = api;
38
38
  const db = require('../db');
39
- const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
39
+ const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED, prMergeSentence } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
40
40
  const hierarchy = require('../hierarchy');
41
41
  const { suggestCriteria } = require('../criterion-suggest');
42
42
  // The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
@@ -1176,12 +1176,13 @@ module.exports = function buildTasksRouter() {
1176
1176
  const builderLogin = await db.getLastClaimHolder(id)
1177
1177
  .then((h) => (h ? h.github_login : req.builder.github_login), () => null);
1178
1178
  const builderLine = prAssign.prBuilderLine(builderLogin);
1179
+ const held = await pageTweakReads.heldForArtistFailClosed(id); // ADR 0341 D7: read before the body names it (task 1004379)
1179
1180
  const body =
1180
1181
  `Auto-opened by GDS server-mediated publish (ADR 0055) for task ${id}.` +
1181
1182
  `${builderLine ? ` ${builderLine}` : ''}\n\n` +
1182
1183
  `${deHashRefs(valueSummary) || '(no value summary)'}\n\n` +
1183
1184
  `Merging triggers .github/workflows/deploy-prod.yml -> ${branding().domains.publicOrigin}. ` +
1184
- `Auto-merge is enabled — GitHub lands it once the required checks pass.`;
1185
+ prMergeSentence(held);
1185
1186
  const pr = await githubPush.openOrFindPullRequest({ branch, title, body });
1186
1187
  // Best-effort by contract (pr-assign.js never rejects); the catch is for a future
1187
1188
  // edit that forgets — an assignment must not be able to fail a pushed ship.
@@ -1191,7 +1192,7 @@ module.exports = function buildTasksRouter() {
1191
1192
  // builder nothing, and the commonest cause is a repo with allow_auto_merge OFF —
1192
1193
  // a permanent condition that reads exactly like a transient blip.
1193
1194
  // task 1004322 (ADR 0341 D7): a page tweak held for its artist is PUBLISHED, never armed.
1194
- const autoMergeResult = (await pageTweakReads.heldForArtistFailClosed(id)) ? HELD_NOT_ARMED : await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1195
+ const autoMergeResult = held ? HELD_NOT_ARMED : await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1195
1196
  const autoMerge = autoMergeResult.ok;
1196
1197
  // task 1139 (ADR 0058): attest the author's trust tier on the pushed sha so the
1197
1198
  // gate-review check can auto-approve a Metic+ author's gate-surface PR with no
@@ -78,6 +78,15 @@ const STAGES = Object.freeze({
78
78
  headline: (id) => `Task #${id} grade failed`,
79
79
  at: 4, currentKind: 'failed',
80
80
  },
81
+ // task 1004379: a page tweak whose grade PASSED and that now waits for its
82
+ // artist (ADR 0341 D7). It sits at completed, which stageFor() would read as a
83
+ // failed grade, so the caller names this stage explicitly (ship-finish.js
84
+ // finishHeld). The grade is done; the confirm node is the artist's to take.
85
+ held: {
86
+ tone: 'info', icon: 'ti-palette', statusWord: 'Waiting for the artist',
87
+ headline: (id) => `Task #${id} passed — waiting for the artist`,
88
+ at: 4, currentKind: 'held',
89
+ },
81
90
  });
82
91
 
83
92
  // Map a post-grade task status to a ship-family card stage. (claimed/review are
@@ -139,6 +148,8 @@ const GRADE_FAILED = 'grade failed';
139
148
  // task 1002892: the confirm node's word when the grade did not RUN. Two words, so
140
149
  // the " → " joint logic that already keeps 'grade failed' intact covers it too.
141
150
  const NOT_GRADED = 'not graded';
151
+ // task 1004379: the confirm node's word while a passed page tweak waits for its artist.
152
+ const AWAITING_ARTIST = 'awaiting artist';
142
153
 
143
154
  // The journey, mapped: one entry per PIPELINE node, conjugated + classified by
144
155
  // position relative to the current node (`cfg.at`). Both renderers consume this
@@ -158,6 +169,7 @@ function trailSteps(cfg) {
158
169
  // cfg.trailWord is set only by the outage override in buildCardModel; every
159
170
  // other caller leaves it undefined and gets GRADE_FAILED exactly as before.
160
171
  if (cfg.currentKind === 'failed') return { word: cfg.trailWord || GRADE_FAILED, state: 'current', checked: false, bold: true };
172
+ if (cfg.currentKind === 'held') return { word: AWAITING_ARTIST, state: 'current', checked: false, bold: true };
161
173
  if (cfg.currentKind === 'done') return { word: f.done, state: 'current', checked: true, bold: true };
162
174
  return { word: f.active, state: 'current', checked: false, bold: true }; // 'active'
163
175
  }
@@ -219,6 +231,7 @@ const DISCIPLINE_SKILL = { ui: 'design' };
219
231
  function nextActionFor(stage, { taskId, discipline, sandboxUrl } = {}) {
220
232
  if (stage === 'shipped') return { kind: 'prompt', label: 'Start next task', cmd: '/builder-start' };
221
233
  if (stage === 'confirmed') return { kind: 'prompt', label: 'Land it', cmd: '/merge-mode' };
234
+ if (stage === 'held') return { kind: 'prompt', label: 'Start next task', cmd: '/builder-start' };
222
235
  if (stage === 'failed') return { kind: 'prompt', label: 'Fix & re-grade', cmd: `node scripts/gds/ship.js ${taskId} --regrade` };
223
236
  if (stage === 'review') return { kind: 'link', label: 'Open in sandbox', url: sandboxUrl || '' };
224
237
  if (stage === 'claimed') {
@@ -391,7 +404,9 @@ function buildCardModel(input = {}) {
391
404
  }
392
405
  // Credits
393
406
  let creditsText = null;
394
- if (stage === 'failed') {
407
+ if (stage === 'held') {
408
+ creditsText = 'none yet — paid when the artist approves and it lands';
409
+ } else if (stage === 'failed') {
395
410
  creditsText = 'none — held at completed';
396
411
  } else if (credits.awarded != null) {
397
412
  const total = fmtInt(credits.total);
@@ -448,6 +463,8 @@ function buildCardModel(input = {}) {
448
463
  // states the automatic reality rather than implying a required command.
449
464
  deployText = 'landing automatically';
450
465
  }
466
+ } else if (stage === 'held') {
467
+ deployText = 'waiting for the artist to approve it in the studio';
451
468
  } else {
452
469
  deployText = 'not deployed';
453
470
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.84",
3
+ "version": "1.21.86",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.84",
9
+ "version": "1.21.86",
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.21.84",
3
+ "version": "1.21.86",
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",
@@ -9330,5 +9330,17 @@
9330
9330
  "id": "1004629",
9331
9331
  "text": "New projects now ask how finished work should ship: Auto (recommended) or Manual. The answer becomes the project's ship mode, which the owner can change later on the settings page."
9332
9332
  }
9333
+ ],
9334
+ "1.21.85": [
9335
+ {
9336
+ "id": "1004379",
9337
+ "text": "A page tweak that passed its grade and is waiting for its artist no longer reports itself as a failed grade: the closing card, the PR description and the credits line now all say it passed and is waiting for the artist's appro"
9338
+ }
9339
+ ],
9340
+ "1.21.86": [
9341
+ {
9342
+ "id": "1004399",
9343
+ "text": "Published module packages are now kept in the project's own data folder instead of inside the installed core, so a core upgrade no longer deletes them; any already stored in the old place are moved over when the server starts."
9344
+ }
9333
9345
  ]
9334
9346
  }
@@ -244,7 +244,8 @@ async function finishHeld({ taskId, summary, postGradeStatus, card }, deps = {})
244
244
  console.error(` Re-run to publish it again (the grade is not re-run): node scripts/gds/ship.js ${taskId}`);
245
245
  }
246
246
  const usage = await (deps.postShipSync || postShipSync)(taskId, 'confirmed');
247
- await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land });
247
+ // task 1004379: name the stage — at 'completed' the card would otherwise read a failed grade.
248
+ await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land, stage: 'held' });
248
249
  return land;
249
250
  }
250
251
 
@@ -270,7 +271,7 @@ function diffstatFor(baseline) {
270
271
  // in-feed HTML to a temp file and prints an [otb-card-html] marker the
271
272
  // builder-ship skill picks up to render it in the chat feed. Every step is
272
273
  // best-effort — a summary card must NEVER fail or undo a completed ship.
273
- async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null }) {
274
+ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null, stage = undefined }) {
274
275
  if (!card) return;
275
276
  try {
276
277
  const shipCard = require('../../modules/lifecycle/ship-card');
@@ -329,6 +330,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
329
330
  }
330
331
 
331
332
  const cardInputs = {
333
+ stage, // task 1004379: 'held' from finishHeld; undefined ⇒ derived from postGradeStatus
332
334
  postGradeStatus,
333
335
  taskId,
334
336
  title: card.title,
@@ -722,7 +722,7 @@ async function shipMain() {
722
722
 
723
723
  printCreditsAndAchievements({
724
724
  postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo,
725
- justUnlocked: postGradeJustUnlocked,
725
+ justUnlocked: postGradeJustUnlocked, held,
726
726
  });
727
727
 
728
728
  // ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
@@ -1107,6 +1107,7 @@ async function regradeMain() {
1107
1107
  const g = gradePost.data.grade;
1108
1108
  recordGradeOutcome({ taskId, grade: g });
1109
1109
  const postGradeStatus = gradePost.data.taskStatus;
1110
+ const held = gradePost.data.held === true; // task 1004322: a page tweak's pass waits for its artist
1110
1111
  printGradeOutcome(g, taskId);
1111
1112
  printCreditsAndAchievements({
1112
1113
  postGradeStatus,
@@ -1115,13 +1116,14 @@ async function regradeMain() {
1115
1116
  creditsMultiplier: gradePost.data.creditsMultiplier ?? null,
1116
1117
  ideaBonusInfo: gradePost.data.ideaBonus ?? null,
1117
1118
  justUnlocked: gradePost.data.just_unlocked || [],
1119
+ held, // task 1004379: a held pass is not 'grade not passed'
1118
1120
  });
1119
1121
 
1120
1122
  // On a pass the task is now confirmed — run the SAME confirmed→shipped tail as a
1121
1123
  // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
1122
1124
  await finishShip({
1123
1125
  taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
1124
- held: gradePost.data.held === true, // task 1004322: a page tweak's pass waits for its artist
1126
+ held,
1125
1127
  postGradeStatus,
1126
1128
  doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
1127
1129
  // task 1270: completion card for the re-grade path too.
@@ -478,9 +478,14 @@ function printGradeOutcome(g, taskId) {
478
478
 
479
479
  // Print the credits line + multiplier breakdown + idea bonus + unlocked
480
480
  // achievements from a grade/confirm response.
481
- function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked }) {
481
+ function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked, held = false }) {
482
482
  console.log('');
483
- if (postGradeStatus === 'completed') {
483
+ if (held) {
484
+ // task 1004379: a page tweak's PASS waits at completed for its artist (ADR
485
+ // 0341 D7). Reading 'completed' as 'grade not passed' told the reader the
486
+ // opposite of what happened.
487
+ console.log(`Credits awarded: ${credits} (none yet — the grade passed; credits land when the artist approves and it merges)`);
488
+ } else if (postGradeStatus === 'completed') {
484
489
  console.log(`Credits awarded: ${credits} (none — task at completed, grade not passed)`);
485
490
  } else if (postGradeStatus === 'confirmed') {
486
491
  console.log(`Credits awarded: ${credits} (none yet — credits land when the task merges to main)`);
@@ -19,6 +19,7 @@ const { plainCardLines } = require('./plain-cards.js');
19
19
  // The server's own reader of a PR's check rollup (task 1003988), reused so the laptop
20
20
  // poll names a red PR exactly the way publish-status does (task 1003764). Dependency-free.
21
21
  const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycle/check-rollup.js');
22
+ const { prMergeSentence } = require('../../modules/lifecycle/page-tweak-hold.js');
22
23
 
23
24
  // ---------- the land outcome (task 1002541, part 1) ----------
24
25
  //
@@ -927,8 +928,7 @@ async function ciLandLocal(taskId, branch, valueSummary, { hold = false } = {})
927
928
  `Auto-opened by ship.js (deploy mode: ci) for task ${taskId}.\n\n` +
928
929
  `${sanitizeGitHubRefs(valueSummary) || '(no value summary)'}\n\n` +
929
930
  `Merging this PR triggers .github/workflows/deploy-prod.yml to deploy to ` +
930
- `${deployTargetNote}. Auto-merge is enabled — GitHub lands it once the ` +
931
- `required checks pass.`;
931
+ `${deployTargetNote}. ${prMergeSentence(hold)}`; // task 1004379: a held round's PR says it waits
932
932
  if (!existing || existing.state === 'CLOSED' || existing.state === 'MERGED') {
933
933
  console.log(' ci-land: opening PR -> main...');
934
934
  if (!ghArgvOk(buildPrCreateArgv(branch, title, body))) {
@@ -8,9 +8,8 @@
8
8
  // 1. stageArtifact — write the tarball under the store dir (a temp name first;
9
9
  // commitArtifact moves it into place inside step 2's transaction). ADR 0338 lets the
10
10
  // store keep tarballs on the control plane's disk while volume is small, so it
11
- // does: <MODULE_STORE_DIR or var/module-store>/<key>/<key>-<version>.tgz. Outside
12
- // the git tree, like var/task-visuals, so a deploy's `git reset --hard` never
13
- // wipes it.
11
+ // does: <MODULE_STORE_DIR or <instance root>/var/module-store>/<key>/<key>-<version>.tgz
12
+ // (see storeDirFor for why the INSTANCE root, task 1004399).
14
13
  // 2. publishVersion — one transaction over the registry tables (core_257): create
15
14
  // the store_modules row on a key's first publish (the publisher becomes its
16
15
  // author), refuse anyone else's key, refuse a delisted key (ADR 0338 D2: delisting
@@ -26,9 +25,105 @@ const crypto = require('node:crypto');
26
25
  const { parseVersion, cmp } = require('../module-loader/semver');
27
26
  const { KEY_RE } = require('../module-loader/manifest-schema');
28
27
 
28
+ // Where the store lives: <INSTANCE ROOT>/var/module-store (task 1004399).
29
+ //
30
+ // THE BUG THIS FIXES. This used to be path.join(__dirname, '..', '..', 'var',
31
+ // 'module-store') — the CORE root. Right in a single checkout, where core root and
32
+ // instance root are one directory; wrong in the consumer layout, where __dirname is
33
+ // node_modules/@bongos/core/src/bongos, so every published tarball was written INSIDE
34
+ // the installed package and every core upgrade (npm ci replaces it) erased them all,
35
+ // leaving registry rows pointing at nothing. Task 1004381 found and fixed the same
36
+ // failure for task visuals (modules/lifecycle/task-visuals.js); this follows it.
37
+ //
38
+ // MODULE_STORE_DIR stays the override (tests, or an operator who wants the tarballs on
39
+ // another disk). The default store carries a `*` .gitignore, because the instance root
40
+ // is a git checkout whose tree `bongos upgrade` requires to be clean.
41
+ let instanceRootCache;
42
+ function instanceRoot() {
43
+ if (instanceRootCache === undefined) {
44
+ try {
45
+ instanceRootCache = require('../instance-config').resolveInstanceRoot();
46
+ } catch (e) {
47
+ // A storage-path lookup must never take the server down: fall back to the
48
+ // historical location, which is right in a single checkout.
49
+ instanceRootCache = path.join(__dirname, '..', '..');
50
+ }
51
+ }
52
+ return instanceRootCache;
53
+ }
54
+
55
+ // PURE given its inputs, so the consumer-layout test can prove where it lands.
56
+ function storeDirFor({ env = process.env, root } = {}) {
57
+ if (env.MODULE_STORE_DIR) return env.MODULE_STORE_DIR;
58
+ return path.join(root || instanceRoot(), 'var', 'module-store');
59
+ }
29
60
 
30
61
  function storeDir() {
31
- return process.env.MODULE_STORE_DIR || path.join(__dirname, '..', '..', 'var', 'module-store');
62
+ return storeDirFor();
63
+ }
64
+
65
+ // The old, in-package location. Read only by migrateLegacyStore.
66
+ function legacyStoreDir() {
67
+ return path.join(__dirname, '..', '..', 'var', 'module-store');
68
+ }
69
+
70
+ // mkdir -p the store and, for the DEFAULT store, drop a `*` .gitignore in it so the
71
+ // tarballs never show as untracked in the instance's git tree (an untracked path
72
+ // there halts the next `bongos upgrade` pre-flight). An override is the operator's
73
+ // own directory and is left bare. Idempotent; the .gitignore write is best-effort.
74
+ const DIR_GITIGNORE = '# Module store tarballs (task 1004399): runtime data, never committed.\n*\n';
75
+ async function ensureStoreDir(dir, { env = process.env } = {}) {
76
+ await fs.promises.mkdir(dir, { recursive: true });
77
+ if (env.MODULE_STORE_DIR || path.resolve(dir) !== path.resolve(storeDirFor({ env }))) return;
78
+ try {
79
+ await fs.promises.writeFile(path.join(dir, '.gitignore'), DIR_GITIGNORE, { flag: 'wx' });
80
+ } catch { /* EEXIST is the steady state; anything else is non-fatal */ }
81
+ }
82
+
83
+ // One-time boot move: every tarball still at the old in-package path moves into the
84
+ // instance store, keeping its <key>/<key>-<version>.tgz place — the registry stores
85
+ // artifact_path RELATIVE to the store dir, so the rows stay right with no data
86
+ // migration. Only well-formed artifacts move (a key directory matching KEY_RE holding
87
+ // "<key>-<version>.tgz"); a leftover .partial is not one. A no-op when both paths are
88
+ // the same directory (a single checkout), the old one is absent, or MODULE_STORE_DIR
89
+ // is set (the old code honoured it too, so nothing was ever written to the legacy
90
+ // path). Never overwrites, never throws: returns { moved, skipped, failed }.
91
+ async function migrateLegacyStore({ from, to = storeDir(), env = process.env } = {}) {
92
+ const out = { moved: 0, skipped: 0, failed: 0 };
93
+ if (from === undefined) {
94
+ if (env.MODULE_STORE_DIR) return out;
95
+ from = legacyStoreDir();
96
+ }
97
+ if (path.resolve(from) === path.resolve(to)) return out;
98
+ let keys;
99
+ try { keys = await fs.promises.readdir(from); } catch { return out; }
100
+ for (const key of keys.filter((k) => KEY_RE.test(k))) {
101
+ let names;
102
+ try { names = await fs.promises.readdir(path.join(from, key)); } catch { continue; }
103
+ for (const name of names) {
104
+ const m = name.startsWith(`${key}-`) && name.endsWith('.tgz') ? name.slice(key.length + 1, -4) : null;
105
+ if (!m || !parseVersion(m)) continue;
106
+ const src = path.join(from, key, name);
107
+ const dest = artifactPath(key, m, { dir: to });
108
+ try {
109
+ try { await fs.promises.access(dest); out.skipped += 1; continue; } catch { /* absent: move it */ }
110
+ await ensureStoreDir(to, { env });
111
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
112
+ try {
113
+ await fs.promises.rename(src, dest);
114
+ } catch (err) {
115
+ if (!err || err.code !== 'EXDEV') throw err;
116
+ // Another filesystem: copy, then remove the original.
117
+ await fs.promises.copyFile(src, dest, fs.constants.COPYFILE_EXCL);
118
+ await fs.promises.unlink(src);
119
+ }
120
+ out.moved += 1;
121
+ } catch {
122
+ out.failed += 1;
123
+ }
124
+ }
125
+ }
126
+ return out;
32
127
  }
33
128
 
34
129
  // Where one version's tarball lives. Key and version are re-validated here so this
@@ -48,6 +143,7 @@ function artifactPath(key, version, { dir = storeDir() } = {}) {
48
143
  // Async throughout so a multi-MB write never holds the web process's event loop.
49
144
  async function stageArtifact(buf, { key, version, dir = storeDir() } = {}) {
50
145
  const final = artifactPath(key, version, { dir });
146
+ await ensureStoreDir(dir);
51
147
  await fs.promises.mkdir(path.dirname(final), { recursive: true });
52
148
  const tmp = `${final}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.partial`;
53
149
  await fs.promises.writeFile(tmp, buf, { flag: 'wx', mode: 0o640 });
@@ -276,5 +372,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
276
372
  module.exports = {
277
373
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
278
374
  isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
279
- delistModule, delistedKeySet,
375
+ delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
280
376
  };
@@ -95,7 +95,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
95
95
  const {
96
96
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
97
97
  resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
98
- delistModule, delistedKeySet,
98
+ delistModule, delistedKeySet, migrateLegacyStore,
99
99
  } = require('../module-store');
100
100
  const {
101
101
  isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
@@ -210,6 +210,12 @@ function contributeModuleDesk() {
210
210
  module.exports = function buildModulesRouter() {
211
211
  contributeModuleRechecksToDocket();
212
212
  contributeModuleDesk();
213
+ // Task 1004399: carry tarballs written inside the installed core package (the old
214
+ // store path) into the instance store before the next upgrade erases them. Async and
215
+ // never fatal; a no-op in a single checkout, where the two paths are one directory.
216
+ migrateLegacyStore()
217
+ .then((r) => { if (r.moved || r.failed) log.info(r, 'module store: moved from the old in-package store'); })
218
+ .catch((err) => log.error({ err: err && err.message }, 'module store: boot move failed'));
213
219
  const router = express.Router();
214
220
  // Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
215
221
  // router starts empty.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.84'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.86'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,164 @@
1
+ // tests/held_tweak_reporting.mjs — a held page tweak is reported as a PASS that
2
+ // waits for its artist, never as a failed grade (task 1004379, ADR 0341 D7).
3
+ //
4
+ // WHY. The first live Tweak Mode rounds (1004345, 1004348) passed their grade and
5
+ // held at completed for the artist, as designed — and then three surfaces told the
6
+ // reader the opposite: the closing card said "grade failed" and offered --regrade,
7
+ // the PR the server opened said "Auto-merge is enabled", and a passing --regrade
8
+ // printed "Credits awarded: 0 (grade not passed)". Each case below drives the real
9
+ // code a held round runs through:
10
+ // 1. finishHeld hands the card a 'held' stage, and that card reads as a pass;
11
+ // 2. the publish route's PR body names the hold (and an ordinary PR is unchanged);
12
+ // 3. the credits line a passing (re-)grade prints on a held round says it passed.
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import http from 'node:http';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ process.env.NODE_ENV = 'test';
20
+ const require = createRequire(import.meta.url);
21
+ const { test, summary } = makeRunner();
22
+
23
+ const shipCard = require('../modules/lifecycle/ship-card.js');
24
+ const shipFinish = require('../scripts/gds/ship-finish.js');
25
+ const shipLand = require('../scripts/gds/ship-land.js');
26
+ const { printCreditsAndAchievements } = require('../scripts/gds/ship-grade-steps.js');
27
+
28
+ const capture = async (fn) => {
29
+ const out = [];
30
+ const orig = console.log;
31
+ console.log = (...a) => out.push(a.join(' '));
32
+ try { await fn(); } finally { console.log = orig; }
33
+ return out.join('\n');
34
+ };
35
+
36
+ // ---- 1. the closing card --------------------------------------------------
37
+
38
+ const twelveMinor = Array.from({ length: 12 }, (_, i) => ({ severity: 'minor', file: `f${i}`, summary: 'nit' }));
39
+
40
+ await test('finishHeld names the held stage to the card, even though the task sits at completed', async () => {
41
+ let cardArgs = null;
42
+ await capture(() => shipFinish.finishHeld({ taskId: 5001, summary: 's', postGradeStatus: 'completed', card: { title: 't' } }, {
43
+ publishHeld: async () => shipLand.landHeld({ prUrl: 'https://github.com/o/r/pull/77' }),
44
+ postShipSync: async () => null,
45
+ printCompletionCard: async (a) => { cardArgs = a; },
46
+ }));
47
+ assert.equal(cardArgs.stage, 'held');
48
+ assert.equal(cardArgs.postGradeStatus, 'completed');
49
+ });
50
+
51
+ await test('a held pass prints a hold card: no "grade failed", no --regrade, a passing grade row', () => {
52
+ const model = shipCard.buildCardModel({
53
+ stage: 'held', postGradeStatus: 'completed', taskId: 5001, title: 't',
54
+ grade: { passed: true, issues: twelveMinor, panel: 4 },
55
+ credits: { awarded: 0 },
56
+ });
57
+ const text = shipCard.renderMonospaceCard(model);
58
+ const html = shipCard.renderCardHtml(model);
59
+ assert.equal(model.stage, 'held');
60
+ assert.match(model.cfg.headline(5001), /waiting for the artist/);
61
+ for (const out of [text, html]) {
62
+ assert.doesNotMatch(out, /grade failed/i, 'the card must not call a pass a failure');
63
+ assert.doesNotMatch(out, /Needs fixes/);
64
+ assert.doesNotMatch(out, /--regrade/);
65
+ assert.doesNotMatch(out, /fail · 12 issues/);
66
+ assert.match(out, /awaiting artist/);
67
+ }
68
+ const row = (k) => (model.rows.find((r) => r.k === k) || {}).v;
69
+ assert.match(row('Grade'), /^pass/);
70
+ assert.match(row('Deploy'), /artist/);
71
+ assert.match(row('Credits'), /artist approves/);
72
+ assert.equal(model.action.cmd, '/builder-start');
73
+ });
74
+
75
+ await test('an ordinary stop at completed (a real failed grade) still reads as a failed grade', () => {
76
+ const model = shipCard.buildCardModel({
77
+ postGradeStatus: 'completed', taskId: 5002, title: 't',
78
+ grade: { passed: false, issues: twelveMinor },
79
+ });
80
+ assert.equal(model.stage, 'failed');
81
+ assert.match(shipCard.renderMonospaceCard(model), /grade failed/);
82
+ assert.match(model.action.cmd, /--regrade/);
83
+ });
84
+
85
+ // ---- 2. the PR body -------------------------------------------------------
86
+
87
+ const express = require('express');
88
+ const api = require('../src/module-api.js');
89
+ const db = require('../modules/lifecycle/db.js');
90
+ const gp = require('../modules/lifecycle/github-push.js');
91
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
92
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
93
+ const hold = require('../modules/lifecycle/page-tweak-hold.js');
94
+
95
+ let heldNow = false;
96
+ let prBody = null;
97
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
98
+ db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
99
+ db.setClaimPublishedSha = async () => {};
100
+ gp.isConfigured = () => true;
101
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
102
+ gp.openOrFindPullRequest = async ({ body }) => { prBody = body; return { number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' }; };
103
+ gp.enableAutoMergeDetailed = async () => ({ ok: true });
104
+ gp.setCommitStatus = async () => true;
105
+ prAssign.assignPullRequest = async () => ({ assigned: true });
106
+ pageTweakReads.heldForArtistFailClosed = async () => heldNow;
107
+
108
+ const app = express();
109
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
110
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
111
+ const server = http.createServer(app);
112
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
113
+ const base = `http://127.0.0.1:${server.address().port}`;
114
+ const publish = async () => {
115
+ prBody = null;
116
+ const res = await fetch(`${base}/tasks/5001/publish-branch`, {
117
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ branch: 'task-5001', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
119
+ });
120
+ return { status: res.status, body: await res.json() };
121
+ };
122
+
123
+ await test("a held round's PR body says it waits for the artist, and never promises auto-merge", async () => {
124
+ heldNow = true;
125
+ const r = await publish();
126
+ assert.equal(r.status, 201, JSON.stringify(r.body));
127
+ assert.equal(r.body.auto_merge, false);
128
+ assert.ok(prBody.endsWith(hold.HELD_PR_SENTENCE), prBody);
129
+ assert.match(prBody, /artist/);
130
+ assert.doesNotMatch(prBody, /Auto-merge is enabled/);
131
+ });
132
+
133
+ await test('every other PR body keeps the auto-merge sentence it always had', async () => {
134
+ heldNow = false;
135
+ const r = await publish();
136
+ assert.equal(r.status, 201);
137
+ assert.equal(r.body.auto_merge, true);
138
+ assert.match(prBody, /Auto-merge is enabled — GitHub lands it once the required checks pass\.$/);
139
+ });
140
+ server.close();
141
+
142
+ // ---- 3. the credits line after a passing (re-)grade -----------------------
143
+
144
+ await test('a passing grade on a held round prints a pass, not "grade not passed"', async () => {
145
+ const text = await capture(() => printCreditsAndAchievements({ postGradeStatus: 'completed', credits: 0, justUnlocked: [], held: true }));
146
+ assert.doesNotMatch(text, /grade not passed/);
147
+ assert.match(text, /grade passed/);
148
+ assert.match(text, /artist approves/);
149
+ });
150
+
151
+ await test('a real failed grade at completed still says the grade did not pass', async () => {
152
+ const text = await capture(() => printCreditsAndAchievements({ postGradeStatus: 'completed', credits: 0, justUnlocked: [] }));
153
+ assert.match(text, /grade not passed/);
154
+ });
155
+
156
+ await test('both grade paths (ship and --regrade) hand the hold to the credits line', () => {
157
+ const fs = require('node:fs');
158
+ const src = fs.readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
159
+ const calls = src.split('printCreditsAndAchievements({').slice(1).map((c) => c.slice(0, c.indexOf('});')));
160
+ const withHold = calls.filter((c) => /\bheld\b/.test(c));
161
+ assert.equal(withHold.length, 2, 'the ship grade and the re-grade both pass held');
162
+ });
163
+
164
+ summary();
@@ -0,0 +1,163 @@
1
+ // tests/module_store_instance_root.mjs
2
+ //
3
+ // Task 1004399: published module tarballs must be stored under the INSTANCE root,
4
+ // never inside the installed core package, so they survive a core upgrade. The
5
+ // sibling of tests/task_visuals_instance_root.mjs (task 1004381), which found the
6
+ // same bug in the task-visuals store.
7
+ //
8
+ // This builds a real consumer tree in a temp dir: a fake installed core package
9
+ // carrying the REAL src/bongos/module-store.js, with its three core requires
10
+ // re-exporting the real files, then proves, through that installed copy:
11
+ // 1. the store is the instance's var/module-store, not inside the package;
12
+ // 2. a published tarball survives the package being deleted and reinstalled,
13
+ // and the registry row's artifact_path still resolves to it;
14
+ // 3. the boot move carries tarballs from the old in-package path, keeping their
15
+ // <key>/ place, once, without overwriting, skipping anything not an artifact,
16
+ // and is a no-op in a single checkout or under MODULE_STORE_DIR;
17
+ // 4. the store keeps the instance's git tree clean (upgrade pre-flight).
18
+ //
19
+ // Run: node tests/module_store_instance_root.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import { spawnSync } from 'node:child_process';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import fs from 'node:fs';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const REAL = (...p) => path.join(REPO, 'src', ...p);
32
+
33
+ let passed = 0, failed = 0;
34
+ async function t(name, fn) {
35
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 3).join('\n ') : e}`); }
37
+ }
38
+
39
+ const TGZ = Buffer.from('1f8b0800000000000003', 'hex');
40
+
41
+ const WORK = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-instance-root-'));
42
+ const CONSUMER = path.join(WORK, 'consumer');
43
+ const CORE = path.join(CONSUMER, 'node_modules', '@bongos', 'core');
44
+ const reexport = (real) => `module.exports = require(${JSON.stringify(real)});\n`;
45
+
46
+ // "npm ci": (re)install the fake core package — the real store, its requires stubbed through.
47
+ function installCore() {
48
+ fs.rmSync(CORE, { recursive: true, force: true });
49
+ fs.mkdirSync(path.join(CORE, 'src', 'bongos'), { recursive: true });
50
+ fs.mkdirSync(path.join(CORE, 'src', 'module-loader'), { recursive: true });
51
+ fs.copyFileSync(REAL('bongos', 'module-store.js'), path.join(CORE, 'src', 'bongos', 'module-store.js'));
52
+ fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'semver.js'), reexport(REAL('module-loader', 'semver.js')));
53
+ fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'manifest-schema.js'), reexport(REAL('module-loader', 'manifest-schema.js')));
54
+ fs.writeFileSync(path.join(CORE, 'src', 'instance-config.js'), reexport(REAL('instance-config.js')));
55
+ }
56
+ // A fresh require of the INSTALLED copy (a restarted server).
57
+ function loadInstalled() {
58
+ const p = path.join(CORE, 'src', 'bongos', 'module-store.js');
59
+ delete require.cache[p];
60
+ return require(p);
61
+ }
62
+ const inside = (child, parent) => {
63
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
64
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
65
+ };
66
+
67
+ fs.mkdirSync(CONSUMER, { recursive: true });
68
+ installCore();
69
+ delete process.env.MODULE_STORE_DIR;
70
+ process.env.BONGOS_INSTANCE_ROOT = CONSUMER;
71
+
72
+ await t('consumer layout: the store is <instance>/var/module-store, NOT inside the core package', () => {
73
+ const ms = loadInstalled();
74
+ const dir = ms.storeDir();
75
+ assert.equal(path.resolve(dir), path.join(CONSUMER, 'var', 'module-store'));
76
+ assert.ok(!inside(dir, CORE), `store ${dir} is inside the core package ${CORE}`);
77
+ });
78
+
79
+ await t('MODULE_STORE_DIR still overrides', () => {
80
+ const ms = loadInstalled();
81
+ assert.equal(ms.storeDirFor({ env: { MODULE_STORE_DIR: '/elsewhere/x' } }), '/elsewhere/x');
82
+ });
83
+
84
+ await t('the checkout copy resolves the same way: an instance root, or the override', () => {
85
+ const { storeDir, storeDirFor } = require('../src/bongos/module-store.js');
86
+ assert.equal(storeDirFor({ env: {}, root: '/inst' }), path.join('/inst', 'var', 'module-store'));
87
+ assert.equal(path.resolve(storeDir()), path.join(CONSUMER, 'var', 'module-store'));
88
+ });
89
+
90
+ let row;
91
+ await t('a published tarball survives a core upgrade (package deleted and reinstalled)', async () => {
92
+ const ms = loadInstalled();
93
+ const staged = await ms.stageArtifact(TGZ, { key: 'weather', version: '1.2.0' });
94
+ await ms.commitArtifact(staged);
95
+ row = { module_key: 'weather', version: '1.2.0', artifact_path: ms.relativeArtifactPath(staged.final) };
96
+ assert.equal(row.artifact_path, 'weather/weather-1.2.0.tgz');
97
+ installCore(); // the upgrade
98
+ const after = loadInstalled();
99
+ const file = after.versionArtifactFile(row);
100
+ assert.ok(fs.existsSync(file), 'the tarball is gone after the upgrade');
101
+ assert.deepEqual(fs.readFileSync(file), TGZ);
102
+ });
103
+
104
+ await t('the default store carries a * .gitignore, so the instance git tree stays clean', () => {
105
+ const gi = fs.readFileSync(path.join(CONSUMER, 'var', 'module-store', '.gitignore'), 'utf8');
106
+ assert.match(gi, /^\*$/m);
107
+ const init = spawnSync('git', ['init', '-q'], { cwd: CONSUMER, encoding: 'utf8' });
108
+ if (init.status !== 0) { console.log(' (git unavailable: .gitignore content checked only)'); return; }
109
+ fs.writeFileSync(path.join(CONSUMER, '.gitignore'), 'node_modules/\n');
110
+ const st = spawnSync('git', ['status', '--porcelain', '--untracked-files=all', '--', 'var'], { cwd: CONSUMER, encoding: 'utf8' });
111
+ assert.equal(st.status, 0);
112
+ assert.equal(st.stdout.trim(), '', `var/ shows in git status:\n${st.stdout}`);
113
+ });
114
+
115
+ await t('boot move: tarballs at the old in-package path move into the instance store, once', async () => {
116
+ const ms = loadInstalled();
117
+ const legacy = path.join(CORE, 'var', 'module-store');
118
+ fs.mkdirSync(path.join(legacy, 'notes'), { recursive: true });
119
+ fs.mkdirSync(path.join(legacy, 'weather'), { recursive: true });
120
+ fs.mkdirSync(path.join(legacy, 'Bad Key'), { recursive: true });
121
+ fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz'), TGZ);
122
+ fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial'), 'half');
123
+ fs.writeFileSync(path.join(legacy, 'notes', 'other-0.1.0.tgz'), 'not this key');
124
+ fs.writeFileSync(path.join(legacy, 'Bad Key', 'Bad Key-1.0.0.tgz'), 'x');
125
+ // weather 1.2.0 already exists in the new store: kept, never overwritten.
126
+ fs.writeFileSync(path.join(legacy, 'weather', 'weather-1.2.0.tgz'), 'legacy copy');
127
+
128
+ const r = await ms.migrateLegacyStore();
129
+ assert.deepEqual(r, { moved: 1, skipped: 1, failed: 0 });
130
+ const store = ms.storeDir();
131
+ assert.deepEqual(fs.readFileSync(path.join(store, 'notes', 'notes-0.1.0.tgz')), TGZ);
132
+ assert.ok(!fs.existsSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz')), 'moved, not copied');
133
+ assert.deepEqual(fs.readFileSync(path.join(store, 'weather', 'weather-1.2.0.tgz')), TGZ, 'the existing file is not overwritten');
134
+ assert.ok(!fs.existsSync(path.join(store, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial')), 'a .partial is not an artifact');
135
+ assert.ok(!fs.existsSync(path.join(store, 'Bad Key')), 'a directory that is not a module key is left alone');
136
+
137
+ // A moved tarball is served through its unchanged registry row.
138
+ const moved = ms.versionArtifactFile({ module_key: 'notes', version: '0.1.0', artifact_path: 'notes/notes-0.1.0.tgz' });
139
+ assert.ok(fs.existsSync(moved));
140
+
141
+ const again = await ms.migrateLegacyStore();
142
+ assert.equal(again.moved, 0, 'the second boot moves nothing');
143
+ });
144
+
145
+ await t('boot move is a no-op in a single checkout and under MODULE_STORE_DIR', async () => {
146
+ const ms = loadInstalled();
147
+ const dir = fs.mkdtempSync(path.join(WORK, 'same-'));
148
+ fs.mkdirSync(path.join(dir, 'notes'));
149
+ fs.writeFileSync(path.join(dir, 'notes', 'notes-0.2.0.tgz'), TGZ);
150
+ assert.deepEqual(await ms.migrateLegacyStore({ from: dir, to: dir }), { moved: 0, skipped: 0, failed: 0 });
151
+ assert.deepEqual(await ms.migrateLegacyStore({ env: { MODULE_STORE_DIR: dir } }), { moved: 0, skipped: 0, failed: 0 });
152
+ assert.ok(fs.existsSync(path.join(dir, 'notes', 'notes-0.2.0.tgz')));
153
+ });
154
+
155
+ await t('the modules router runs the boot move when it is built', () => {
156
+ const src = fs.readFileSync(path.join(REPO, 'src', 'bongos', 'routes', 'modules.js'), 'utf8');
157
+ const body = src.slice(src.indexOf('module.exports = function buildModulesRouter('));
158
+ assert.ok(body.indexOf('migrateLegacyStore()') > 0 && body.indexOf('migrateLegacyStore()') < body.indexOf('express.Router()'));
159
+ });
160
+
161
+ fs.rmSync(WORK, { recursive: true, force: true });
162
+ console.log(`\n${passed} passed, ${failed} failed`);
163
+ if (failed) process.exitCode = 1;
@@ -206,8 +206,7 @@ await test('finishShip routes a held pass away from mergeAndDeploy (the source s
206
206
  const mergeAt = body.indexOf('await mergeAndDeploy(');
207
207
  assert.ok(holdAt > 0 && mergeAt > holdAt, 'the hold is decided BEFORE any merge is attempted');
208
208
  const flow = fs.readFileSync(require.resolve('../scripts/gds/ship-flow.js'), 'utf8');
209
- assert.equal((flow.match(/held = gradePost\.data\.held === true/g) || []).length, 1, 'shipMain reads the hold off the grade response');
210
- assert.equal((flow.match(/held: gradePost\.data\.held === true/g) || []).length, 1, 'regradeMain does too');
209
+ assert.equal((flow.match(/\bheld = gradePost\.data\.held === true/g) || []).length, 2, 'shipMain and regradeMain both read the hold off the grade response');
211
210
  assert.match(flow, /tweakHold\.isHeldForArtist\(task, tRes\.data\.grade/, 'a re-run of a held task re-publishes instead of re-grading');
212
211
  });
213
212