@bongos/core 1.21.102 → 1.21.104

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.102",
6
- "core_contract": "1.21.102",
7
- "source_commit": "f552db78c82ef1ca4418a9de313e150bb29ce4b1",
5
+ "core_version": "1.21.104",
6
+ "core_contract": "1.21.104",
7
+ "source_commit": "cfc8c3651ea3f442385e6d0ba405c6a578b4d83a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T13:15:03.097Z",
9
+ "built_at": "2026-10-09T13:49:17.283Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 592,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3019,
14
+ "functional_verbatim": 3021,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3640,
20
- "tree_sha256": "834d88c63001764c8ed72f393a6c49d3d123469f268cf129a13cab0b342e5ebe",
19
+ "file_count": 3642,
20
+ "tree_sha256": "b23d12f504071337f2882e99100b69132448daa2e65560c851411b6a667c5f67",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -207,7 +207,7 @@
207
207
  {
208
208
  "path": ".claude/skills/planning-session/SKILL.md",
209
209
  "mode": "0000644",
210
- "sha256": "692b9954a308f817798c8ce6c6811a61a020c588555073f7ba99f75044ab3b18"
210
+ "sha256": "0447f0526aaf9735f47cb52fe7423914f8aba4cfbeb0f9b56fa49e0d4e5073fd"
211
211
  },
212
212
  {
213
213
  "path": ".claude/skills/priority-session/SKILL.md",
@@ -2872,7 +2872,7 @@
2872
2872
  {
2873
2873
  "path": "docs/module-api-changelog.md",
2874
2874
  "mode": "0000644",
2875
- "sha256": "b44e9230778cf7b2aafd8d7971fb862c00ab3c8c667da525dc83ceb6238a0908"
2875
+ "sha256": "6e7ad3fddfaca1cfb1dfe79cbdb4a5588007b30eedd458df7212a971da96e324"
2876
2876
  },
2877
2877
  {
2878
2878
  "path": "docs/modules-contract.md",
@@ -2997,7 +2997,7 @@
2997
2997
  {
2998
2998
  "path": "docs/recipes/core-release-pipeline.md",
2999
2999
  "mode": "0000644",
3000
- "sha256": "23ebe6ce28667a82c457badf8db3116c5dc86e19fd799603bbfe8b22be4fc560"
3000
+ "sha256": "00d1879609d950f379acdf411e8c91acc6da0b08838c49a338257433af24ceb8"
3001
3001
  },
3002
3002
  {
3003
3003
  "path": "docs/recipes/failed-ship-recovery-proof.md",
@@ -7109,6 +7109,11 @@
7109
7109
  "mode": "0000644",
7110
7110
  "sha256": "16667b27f462627277da092dd0cf8905cf7144f1a959fdaf3174a9fb41a91da4"
7111
7111
  },
7112
+ {
7113
+ "path": "modules/lifecycle/merge-reach.js",
7114
+ "mode": "0000644",
7115
+ "sha256": "f800bcd7e9f78cd680a06fa3242e70deed97c3399a435e783bec7288a5708cb8"
7116
+ },
7112
7117
  {
7113
7118
  "path": "modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql",
7114
7119
  "mode": "0000644",
@@ -7427,7 +7432,7 @@
7427
7432
  {
7428
7433
  "path": "modules/lifecycle/ship-card.js",
7429
7434
  "mode": "0000644",
7430
- "sha256": "fc803defc468a0e625d82f305d00118ec77bb63d74d7b75b4d3ba03bdd16659d"
7435
+ "sha256": "db49d616bad9ae851bb86f3729252290827db46cd6a6f3c1f86c60c9b9d2691a"
7431
7436
  },
7432
7437
  {
7433
7438
  "path": "modules/lifecycle/ship-files.js",
@@ -10132,12 +10137,12 @@
10132
10137
  {
10133
10138
  "path": "package-lock.json",
10134
10139
  "mode": "0000644",
10135
- "sha256": "4abc8512ca8578d3a43a7d915574dc473061e2e77cd20e3902c5478af77a671f"
10140
+ "sha256": "7f4d097ca5d827a3fad698b3e3fbbedc38f7469e29b565cfed34489f4eaab008"
10136
10141
  },
10137
10142
  {
10138
10143
  "path": "package.json",
10139
10144
  "mode": "0000644",
10140
- "sha256": "7b6b52ae08358107e824752af2427b6efaa33d7ea5bcf9a0b464584eef6708a5"
10145
+ "sha256": "a0fb2f62847ad58e78ec122f44bb7e97281f124bdfd0324c767a50bfe26b5a9e"
10141
10146
  },
10142
10147
  {
10143
10148
  "path": "public-docs/index.html",
@@ -10157,7 +10162,7 @@
10157
10162
  {
10158
10163
  "path": "release-notes.json",
10159
10164
  "mode": "0000644",
10160
- "sha256": "947800c2b145b25a07f38bdfc1274ba9316de0b1bccc35e43de3ea2ddc44f7a3"
10165
+ "sha256": "f65d5a86053502037145a87a3e996ce4ff32c84651d4a8f8afe62c486172ef2d"
10161
10166
  },
10162
10167
  {
10163
10168
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10562,7 +10567,7 @@
10562
10567
  {
10563
10568
  "path": "scripts/gds/deploy-divergence.js",
10564
10569
  "mode": "0000644",
10565
- "sha256": "bffaceec8b47b238f4a7911aee10a651b45cd75e0c5a67b2b8e3daa90e3cd54a"
10570
+ "sha256": "0fd4f72d74f1f39ebbd6267f485a24705a0c20706a2475507c895e726b6f2287"
10566
10571
  },
10567
10572
  {
10568
10573
  "path": "scripts/gds/deploy-main-audit-gate.sh",
@@ -11212,7 +11217,7 @@
11212
11217
  {
11213
11218
  "path": "scripts/gds/provision-repo.js",
11214
11219
  "mode": "0000644",
11215
- "sha256": "8e2cf2e362b5e38832b53720a84ee0fb9003f9062cf9b7d03353a62fde936566"
11220
+ "sha256": "a52e17d4341c7498d9fc204bfaa7447305a19924e8e05f0fde10583eb1f0a6da"
11216
11221
  },
11217
11222
  {
11218
11223
  "path": "scripts/gds/provision-teardown.js",
@@ -11632,12 +11637,12 @@
11632
11637
  {
11633
11638
  "path": "scripts/gds/ship-finish.js",
11634
11639
  "mode": "0000644",
11635
- "sha256": "23856ecf2ed0224fcc69fa4e67413d2248462813896e3b832a20f3a2d0a886e6"
11640
+ "sha256": "03bd3bacc00dfb644f25e22bf90a2864700cb570dcdf051e6c9fb9ca3edfb11a"
11636
11641
  },
11637
11642
  {
11638
11643
  "path": "scripts/gds/ship-flow.js",
11639
11644
  "mode": "0000644",
11640
- "sha256": "d114ae690d86270db76770fc3531a5530d1ab64a031a2528368864f4b330ac56"
11645
+ "sha256": "ad768320095b1d6693e6da5cee1f8e857ac69cbbfbea0a64dfeaa7092c432de2"
11641
11646
  },
11642
11647
  {
11643
11648
  "path": "scripts/gds/ship-git.js",
@@ -11717,7 +11722,7 @@
11717
11722
  {
11718
11723
  "path": "scripts/gds/ship-visual.js",
11719
11724
  "mode": "0000644",
11720
- "sha256": "21ce15f1e49388bb3f8a46b9cfd8b05b23b77efbe93228ca04d9b13740e6d901"
11725
+ "sha256": "a97c5628609b622ad8972e93af13ff32a85138c350756f4895bea24d469bc2fc"
11721
11726
  },
11722
11727
  {
11723
11728
  "path": "scripts/gds/ship.js",
@@ -12432,7 +12437,7 @@
12432
12437
  {
12433
12438
  "path": "src/module-api.js",
12434
12439
  "mode": "0000644",
12435
- "sha256": "090a1a041a5e03ddfd2bb4c93ff9e28407a1cb662da2492e3741450900282f44"
12440
+ "sha256": "a00e2f13c266f639d3052f84844f2f38634759e57cfd81830667a9bf1e3e7be7"
12436
12441
  },
12437
12442
  {
12438
12443
  "path": "src/module-loader/catalog.js",
@@ -13437,7 +13442,7 @@
13437
13442
  {
13438
13443
  "path": "tests/core_upgrade_runner.mjs",
13439
13444
  "mode": "0000644",
13440
- "sha256": "1c6e820ed45b489749f68f0cc00615fe2c4d6a4b08312316897cd1777c82b4dc"
13445
+ "sha256": "33507dcd145bca157be08b9c8b55ad7d2c3ebae07ab8c94af7af9e235ac60769"
13441
13446
  },
13442
13447
  {
13443
13448
  "path": "tests/cosmos_token_contrast.mjs",
@@ -13647,7 +13652,7 @@
13647
13652
  {
13648
13653
  "path": "tests/deploy_divergence_line.mjs",
13649
13654
  "mode": "0000644",
13650
- "sha256": "3b2f0623db91ffecb0f7a52380b2f50a5e6e87d82d3e3d1a9e40944037d28629"
13655
+ "sha256": "0eb792d18f705ba4990936822195d931802bad7f49b951633173446d5b7f2cba"
13651
13656
  },
13652
13657
  {
13653
13658
  "path": "tests/deploy_instance.mjs",
@@ -16517,7 +16522,7 @@
16517
16522
  {
16518
16523
  "path": "tests/provision.mjs",
16519
16524
  "mode": "0000644",
16520
- "sha256": "64ffc593089622c6901669a29eee6a0d75ae6b47cbfc0729e2c8eae09e8386e1"
16525
+ "sha256": "71ea3289a3007e08249cfb309272208c6ec740d7345d8fbf9638e81b6ece5010"
16521
16526
  },
16522
16527
  {
16523
16528
  "path": "tests/provision_account_identity.mjs",
@@ -17337,7 +17342,7 @@
17337
17342
  {
17338
17343
  "path": "tests/ship_card.mjs",
17339
17344
  "mode": "0000644",
17340
- "sha256": "d7214e2916f124462543eb05f68163c21a6d34c2d6fec24691ce3f865510c298"
17345
+ "sha256": "e79fdf44d85523853ebbcefe8fcdae1806f258e9c546d8f086a46a806016f085"
17341
17346
  },
17342
17347
  {
17343
17348
  "path": "tests/ship_card_injection.mjs",
@@ -17439,6 +17444,11 @@
17439
17444
  "mode": "0000644",
17440
17445
  "sha256": "75aa59accad12d197202457ed36281e5d3eca1040762a5045972c4b10cf09006"
17441
17446
  },
17447
+ {
17448
+ "path": "tests/ship_visual_regrade.mjs",
17449
+ "mode": "0000644",
17450
+ "sha256": "4e469f6f5801032dff196a57a84eeb3603e7a53760144a7ed4d582699c9747f9"
17451
+ },
17442
17452
  {
17443
17453
  "path": "tests/ship_zero_commit.mjs",
17444
17454
  "mode": "0000644",
@@ -151,7 +151,7 @@ For the version being planned, **group the agreed criteria into goals**. For eac
151
151
  - **`scope_modules`** — the module **keys** (NOT file paths) whose files this goal's members may touch. **This is the wall, and it is set here and only here — never self-granted.** Keep it tight: the modules the goal's work actually needs. Widening later goes through an explicit request a trusted rank approves (`POST /goals/:id/scope`; Archon for a `protected` module) — BV1.R64.
152
152
  > **The valid key list is the source of truth in [`src/bongos/module-scope-map.js`](../../../src/bongos/module-scope-map.js) — read it, don't trust this list.** It exports `moduleKeys()` (all keys), `protectedModules()` / `isProtectedModule(key)` (which are PROTECTED), and `scopeForModule(key)` (a key's globs + `protected` flag). Each key maps to path-globs; `protected` is **derived** (the key's globs overlap the trust/ship-pipeline/migration/authz core), and a goal scoped to any protected key is **Archon-only** to create. The current roster (run `node -e "const m=require('./src/bongos/module-scope-map');console.log(m.moduleKeys().map(k=>k+(m.isProtectedModule(k)?' 🔒':'')).join('\n'))"` to print it live):
153
153
  > - **No roster is written down here on purpose.** The previous snapshot went stale within a release (it still omitted a key that had been added), which is exactly the failure a "read it live" rule prevents. Run the one-liner above; `protected` is derived from glob overlap, so it changes when the globs change.
154
- > - **That file is the SOURCE. The RUNNING SERVER is the GATE.** `POST /goals` validates `scope_modules` against the roster the LIVE server loaded — not the one in your checkout — so a key added since the last deploy is valid here and refused on the wire. Merging to main deploys nothing (gate 3 of [the release pipeline](../../../docs/recipes/core-release-pipeline.md)), and this is not a rare race: the module roster grows precisely during the versions when planning sessions run. On 2026-08-16 goal 1000069 was created with `government` in scope and came back `unknown: ["government"]` — the key was on main; the site was a release behind. **The SessionStart deploy-state line already tells you whether you are exposed.** If it says the site is behind main, treat any recently-added key as suspect before you compose the goal, not after.
154
+ > - **That file is the SOURCE. The RUNNING SERVER is the GATE.** `POST /goals` validates `scope_modules` against the roster the LIVE server loaded — not the one in your checkout — so a key added since the last deploy is valid here and refused on the wire. The site picks up a merge only on its next redeploy ([who sees a merge](../../../docs/recipes/core-release-pipeline.md#who-sees-a-merge-and-when)), and this is not a rare race: the module roster grows precisely during the versions when planning sessions run. On 2026-08-16 goal 1000069 was created with `government` in scope and came back `unknown: ["government"]` — the key was on main; the site was a release behind. **The SessionStart deploy-state line already tells you whether you are exposed.** If it says the site is behind main, treat any recently-added key as suspect before you compose the goal, not after.
155
155
  > - **The refusal is not a dead end — it hands you the answer.** `unknown_module` comes back with `valid: [...]`: the LIVE roster, from the server that just refused you. One rejected call tells you exactly what will be accepted, so read it and re-point rather than trying keys one at a time. (`tests/goal_unknown_module_hint.mjs` pins that the refusal keeps carrying it, because this instruction is only true while it does.)
156
156
  > - **Prefer a deployed alias where one exists, and write the re-point down.** Goal 1000069 was walled to the deprecated `governance` alias, which ADR 0174 deliberately kept carrying BOTH sets of globs through the transition — so the wall was identical, not weaker — and the goal description recorded that `scope_modules` should be re-pointed once the newer release went live. A goal walled to an alias with no note is a goal nobody re-points.
157
157
  - **No covering key? → file a module-map prep task FIRST.** Every `scope_modules` entry must be a real key from `moduleKeys()`. If a goal's planned work touches file paths that **no existing key covers** (e.g. a brand-new module dir, or code still fused under `src/bongos/` that isn't yet broken out as its own key), the scope wall cannot place that work. Do **not** invent a key. Instead, **add a prep task to this version** — `kind='refactor'`, scoped to `kernel` (the module that owns `module-scope-map.js`) — to extend `MODULE_GLOBS` so a key covers those paths, and make the dependent goal's tasks depend on it. Adding/editing a key is itself a `kernel`-scoped (Archon-gated) change, so flag it for Archon sign-off in this session.
@@ -3041,5 +3041,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3041
3041
  landed since 1.21.100 with no explicit bump. run 37933684016. (task 1002620)
3042
3042
  1.21.102 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3043
3043
  landed since 1.21.101 with no explicit bump. run 37935415352. (task 1002620)
3044
+ 1.21.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3045
+ landed since 1.21.102 with no explicit bump. run 37936846665. (task 1002620)
3046
+ 1.21.104 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3047
+ landed since 1.21.103 with no explicit bump. run 37939497390. (task 1002620)
3044
3048
  ---------------------------------------------------------------------------
3045
3049
  ```
@@ -1,10 +1,17 @@
1
1
  # Recipe — the core release pipeline: how a change to Cloud Bongos reaches a live instance
2
2
 
3
- > **The map.** Three sibling recipes each cover one leg — [packaging-the-core](packaging-the-core.md) (build the artifact), [private-npm-distribution](private-npm-distribution.md) (publish it), [upgrading-the-core](upgrading-the-core.md) (consume it). This file is the thing that connects them, and it exists to state one fact loudly:
4
- >
5
- > ## **Merging to `main` ships nothing.**
3
+ ## Who sees a merge, and when
4
+
5
+ | Who | Gets a merged change | How to check |
6
+ |---|---|---|
7
+ | **cloudbongos.com** — the projects hub and the builders hall | on **every merge**, within minutes: it redeploys from `main` | its `/version`; the ship card's **Deploy** row reads it: *"live at `<sha>` (includes this merge \| not yet)"* |
8
+ | **Projects hosted on the platform** | only when a **published core release** carries it (auto-published per merge, [ADR 0161](../adr/0161-publish-on-merge.md)) **and that project's pin moves** — a patch-channel subscription or a core-upgrade | that project's `/version` `coreVersion`; the ship card's **Projects** row states the rule |
9
+
10
+ So a shipped task is live on the hub minutes after its merge, and on a hosted project only once that project's pin has moved — say which one you mean. The wording has one source, [`modules/lifecycle/merge-reach.js`](../../modules/lifecycle/merge-reach.js); the ship card, `deploy-divergence.js` and the SessionStart line all print it (task 1004682). `/recall who sees a merge` finds the same rule as learning 1000291.
11
+
12
+ > **The map.** Three sibling recipes each cover one leg — [packaging-the-core](packaging-the-core.md) (build the artifact), [private-npm-distribution](private-npm-distribution.md) (publish it), [upgrading-the-core](upgrading-the-core.md) (consume it). This file is the thing that connects them.
6
13
  >
7
- > A change that has landed on `main` is running on **zero** instances. It reaches users only after someone bumps `CORE_VERSION`, cuts an artifact, and moves an instance's pin. Those are three separate acts by design ([ADR 0137](../adr/0137-upstream-publish-policy-manual-owner-gated.md)) — and every layer around them reports success in the meantime.
14
+ > **History, not the current rule.** Under [ADR 0137](../adr/0137-upstream-publish-policy-manual-owner-gated.md) (manual, owner-gated publish) a merge reached no instance at all, and this file opened with "Merging to `main` ships nothing." [ADR 0161](../adr/0161-publish-on-merge.md) superseded that. Sessions that kept repeating the old line told the owner that live work was not live. The gates below still exist; for patches they now run unattended.
8
15
 
9
16
  > ## Working on a hosted project? Stop here — nothing in this file is yours to run.
10
17
  >
@@ -70,7 +77,7 @@ The ordinary claim→ship loop: `/builder-claim N` → build → `/builder-ship`
70
77
 
71
78
  Landing is CI-gated (tasks 1002610/1002611/1002619, ADR 0159): every PR runs the **`unit`** check — the DB-free suite plus the fitness gates, via `npm test` — and branch protection **requires** it, so a red suite blocks the merge. PRs touching the hard-floor safety surfaces (`CODEOWNERS` (instance-side)) additionally need the owner's review. The owner kill-switch is `node scripts/gds/gating.js on|off|status` — reversible by construction, snapshot-restored.
72
79
 
73
- **Stop here and nothing happens to any user.** This is the gate that feels like the finish line and is not.
80
+ **Stop here and the hub has it within minutes; no hosted project does.** This is the gate that feels like the finish line for every project, and is one only for the hub (see [Who sees a merge](#who-sees-a-merge-and-when)).
74
81
 
75
82
  ## Gate 2 — cut a release
76
83
 
@@ -334,8 +341,8 @@ node scripts/gds/deploy-divergence.js --url https://<instance-host>
334
341
 
335
342
  It reports the two gaps **separately**, because they have different fixes:
336
343
 
337
- - **UNRELEASED** — commits on `main` past the last `CORE_VERSION` bump. *No instance can take these at any pin* — no version contains them. Fix: bump + land (gate 2a).
338
- - **UNDEPLOYED** — the live instance's `coreVersion` is behind `main`'s. A release exists; this instance has not moved its pin. Fix: gate 3.
344
+ - **UNRELEASED** — commits on `main` past the last `CORE_VERSION` bump. *No hosted project can take these at any pin* — no published version contains them yet. The publish lane (ADR 0161) normally carries a merge within minutes; a gap that stays means the lane is disarmed or red.
345
+ - **UNDEPLOYED** — the live instance's `coreVersion` is behind `main`'s. A release exists it has not picked up: on the hub, its redeploy is late or stuck; on a hosted project, its pin has not moved (gate 3).
339
346
 
340
347
  Exit codes: `0` aligned, `1` diverged, `2` undetermined.
341
348
 
@@ -0,0 +1,56 @@
1
+ // modules/lifecycle/merge-reach.js — who sees a merge, and when. The ONE source of
2
+ // the sentence (task 1004682).
3
+ //
4
+ // Two audiences, two rules:
5
+ // 1. The hub this checkout ships to (cloudbongos.com: the projects hub and the
6
+ // builders hall) redeploys from main on every merge, within minutes.
7
+ // 2. Projects hosted on the platform get a change only when a published core
8
+ // release carries it (auto-published per merge, ADR 0161) AND their pin moves
9
+ // (a patch-channel subscription or a core-upgrade).
10
+ //
11
+ // Before this, deploy-divergence.js printed "Merging to main deploys nothing" on
12
+ // every ship — true under ADR 0137, false since ADR 0161 — and sessions repeated
13
+ // it to the owner about work that was in fact live. The ship card, the divergence
14
+ // report and the SessionStart line all read their wording from here, so the three
15
+ // cannot drift apart again.
16
+ //
17
+ // PURE and dependency-free: callers pass an already-sanitised reading (host, short
18
+ // sha, inclusion), never a raw network response — some of this text is injected
19
+ // into an agent's SessionStart context.
20
+
21
+ 'use strict';
22
+
23
+ const HOSTED_LINE = 'Hosted projects: only when a published core release carries the change and their pin moves (patch-channel subscription or a core-upgrade).';
24
+
25
+ function hubRule(host) {
26
+ return `${host || 'The hub'} redeploys from main on every merge, within minutes`;
27
+ }
28
+
29
+ // includes: true (the live build carries this merge), false (not yet), null (cannot tell).
30
+ function hubLine({ host = null, liveShort = null, includes = null } = {}) {
31
+ if (!liveShort) return `${hubRule(host)}.`;
32
+ const state = includes === true ? 'includes this merge'
33
+ : includes === false ? 'not yet'
34
+ : 'cannot tell yet whether it includes this merge';
35
+ return `${host || 'The hub'}: live at ${liveShort} (${state}) — it redeploys from main on every merge, within minutes.`;
36
+ }
37
+
38
+ // Re-bound a reading that may have travelled (a land-watch breadcrumb file) before
39
+ // it is printed: host-shaped, hex, boolean — anything else is dropped. A reading with
40
+ // no usable host is null, so the caller keeps its own fallback.
41
+ function boundReading(r) {
42
+ const host = r && typeof r.host === 'string' && /^[A-Za-z0-9.:_-]{1,120}$/.test(r.host) ? r.host : null;
43
+ if (!host) return null;
44
+ return {
45
+ host,
46
+ liveShort: typeof r.liveShort === 'string' && /^[0-9a-f]{4,40}$/i.test(r.liveShort) ? r.liveShort : null,
47
+ includes: typeof r.includes === 'boolean' ? r.includes : null,
48
+ };
49
+ }
50
+
51
+ // The two-line wording, in order: [hub, hosted projects].
52
+ function whoSeesAMerge(reading) {
53
+ return [hubLine(reading || {}), HOSTED_LINE];
54
+ }
55
+
56
+ module.exports = { HOSTED_LINE, hubRule, whoSeesAMerge, boundReading };
@@ -23,6 +23,7 @@ const fs = require('node:fs');
23
23
  const os = require('node:os');
24
24
  const path = require('node:path');
25
25
  const { branding } = require('../../src/module-api');
26
+ const { whoSeesAMerge, boundReading } = require('./merge-reach.js');
26
27
 
27
28
  // tone -> the widget's banner color family. 'neutral' has no semantic CSS var, so
28
29
  // every tone resolves through this map (never `--color-background-${tone}`).
@@ -445,9 +446,20 @@ function buildCardModel(input = {}) {
445
446
  // Deploy
446
447
  let deployText;
447
448
  let deployCommit = null;
449
+ let hostedText = null;
448
450
  if (stage === 'shipped') {
449
451
  deployCommit = input.deploy && input.deploy.commit ? String(input.deploy.commit) : null;
450
- deployText = deployCommit ? `live · ${deployCommit}` : 'live';
452
+ const reach = boundReading(input.deploy && input.deploy.reach);
453
+ if (reach) {
454
+ // task 1004682: who sees this merge, in the one shared wording
455
+ // (merge-reach.js) — the hub's live build as read, then the hosted rule.
456
+ // A bare "live · <sha>" read as "every user has it", which is only true
457
+ // of the hub. The reading can come back from a breadcrumb file, so
458
+ // boundReading re-bounds it (host-shaped, hex, boolean) before printing.
459
+ [deployText, hostedText] = whoSeesAMerge(reach);
460
+ } else {
461
+ deployText = deployCommit ? `live · ${deployCommit}` : 'live';
462
+ }
451
463
  } else if (stage === 'confirmed') {
452
464
  // task 1002541: this line used to be unconditional, driven by the STATUS
453
465
  // alone — so a merge that bailed still reported "landing automatically".
@@ -471,6 +483,7 @@ function buildCardModel(input = {}) {
471
483
 
472
484
  addRow('Value', value); addRow('Changed', changed); addRow('Grade', gradeText);
473
485
  addRow('Credits', creditsText); addRow('Usage', usageText); addRow('Deploy', deployText);
486
+ addRow('Projects', hostedText);
474
487
  // task 1003150: the session-boundary nudge. Not a row — it qualifies the
475
488
  // Next-> action rather than reporting a fact about the task, so both renderers
476
489
  // hang it off the action. Usually null.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.102",
3
+ "version": "1.21.104",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.102",
9
+ "version": "1.21.104",
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.102",
3
+ "version": "1.21.104",
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",
@@ -9450,5 +9450,25 @@
9450
9450
  "id": "1004588",
9451
9451
  "text": "The guilds test now sets up everything it needs on a brand-new database, so it runs all the way through instead of stopping early. It also checks, without needing a database, that its setup keeps up with the tables the guild c"
9452
9452
  }
9453
+ ],
9454
+ "1.21.103": [
9455
+ {
9456
+ "id": "1004663",
9457
+ "text": "Two test suites that always failed on Windows machines now pass there, so a builder on Windows can trust a red result again. Two checks that had been passing without really checking anything now check properly."
9458
+ },
9459
+ {
9460
+ "id": "1004682",
9461
+ "text": "Ship reports now say plainly who sees a merge. The old line \"Merging to main deploys nothing\" is gone: it stopped being true when every merge began publishing automatically. A shipped card now reads the live site and says, for"
9462
+ },
9463
+ {
9464
+ "id": "1004684",
9465
+ "text": "New house rule, Slop Bongo: when an AI realises it was wrong about something, it now says 'Bongo Slop Detected, Filing Bug' in chat and files a bug against the doc or message that misled it, so the same mistake stops repeating"
9466
+ }
9467
+ ],
9468
+ "1.21.104": [
9469
+ {
9470
+ "id": "1004665",
9471
+ "text": "When a builder re-runs a review and attaches a screenshot, the screenshot now actually gets attached to the task. Before, it was quietly thrown away, so reviewers asking for a picture never saw one unless someone added it by h"
9472
+ }
9453
9473
  ]
9454
9474
  }
@@ -1,20 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  // Compare what a live instance is SERVING against what is on main.
3
3
  //
4
- // Merging to main deploys nothing (ADR 0108/0137 — publishing is manual and
5
- // owner-gated). That is by design, but every layer around it still reports
6
- // success: /builder-ship prints success, the publish-reconciler flips
7
- // confirmed → shipped, and credits pay out on "land". Nothing compared live
8
- // against main, so between 2026-07-24 and 2026-08-03 a hundred and seventy-one
9
- // commits — including a whole governance permission system — sat on main while
10
- // cloudbongos.com served 1.18.17, and nobody noticed for ten days (blocker
11
- // 1000097 / task 1002521). This script is the missing comparison.
4
+ // WHO SEES A MERGE (task 1004682; the wording lives in modules/lifecycle/merge-reach.js):
5
+ // - The hub this checkout ships to (cloudbongos.com: the projects hub and the
6
+ // builders hall) redeploys from main on every merge, within minutes.
7
+ // - Projects hosted on the platform get a change only when a published core
8
+ // release carries it (auto-published per merge, ADR 0161) and their pin moves
9
+ // (patch-channel subscription or a core-upgrade).
10
+ // This script used to print "Merging to main deploys nothing" — the ADR 0137 rule
11
+ // (manual, owner-gated publish), false since ADR 0161 — and sessions repeated it
12
+ // to the owner about work that was live. It now READS the hub's /version and says
13
+ // whether the live build includes main's tip, instead of a blanket claim.
14
+ //
15
+ // Why it exists at all: between 2026-07-24 and 2026-08-03 a hundred and
16
+ // seventy-one commits — including a whole governance permission system — sat on
17
+ // main while cloudbongos.com served 1.18.17, every layer reported success, and
18
+ // nobody noticed for ten days (blocker 1000097 / task 1002521). This script is
19
+ // the comparison that was missing.
12
20
  //
13
21
  // Three independent gaps, all reported:
14
- // 1. UNRELEASED — commits on main since the last CORE_VERSION bump. No
15
- // instance can take these at any pin, because no version carries them.
22
+ // 1. UNRELEASED — commits on main since the last CORE_VERSION bump. No hosted
23
+ // project can take these at any pin, because no published version carries them.
16
24
  // 2. UNDEPLOYED — the live instance's coreVersion is behind the CORE_VERSION
17
- // on main. A release exists; this instance has not moved its pin to it.
25
+ // on main. A release exists that it has not picked up: on the hub, its
26
+ // redeploy is late or stuck; on a hosted project, its pin has not moved.
18
27
  // 3. SCHEMA BEHIND — live's /version reports schemaPending > 0: its database
19
28
  // lacks migrations its running code ships (the 2026-07-04 502 outage
20
29
  // shape; task 1002002). An instance too old to report it is not flagged.
@@ -35,6 +44,7 @@ const path = require('node:path');
35
44
  const { execSync } = require('node:child_process');
36
45
 
37
46
  const { API_BASE, arg, hasFlag } = require('./cli-lib');
47
+ const { whoSeesAMerge, hubRule, HOSTED_LINE } = require('../../modules/lifecycle/merge-reach.js');
38
48
 
39
49
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
40
50
  const MODULE_API = path.join(REPO_ROOT, 'src', 'module-api.js');
@@ -99,6 +109,51 @@ function unreleasedSince(mainRef) {
99
109
  };
100
110
  }
101
111
 
112
+ // Does the live build include commit `sha`? true / false, or null when this
113
+ // checkout cannot tell (task 1004682). Two ways to know, tried in order:
114
+ // 1. The /version commit is one this repo has (a box that runs this checkout
115
+ // directly) — plain ancestry.
116
+ // 2. Otherwise the commit is the hub's OWN repo (it consumes the core as a
117
+ // published package — measured 2026-10-09: cloudbongos.com reports a commit
118
+ // core main does not contain, at the coreVersion main declares). Then: find the
119
+ // first CORE_VERSION bump on main at or after `sha` — the release that carries
120
+ // it — and compare the live coreVersion against it. No bump yet ⇒ not yet.
121
+ // Both inputs reach git, so both are hex-or-nothing and the ref is a fixed name.
122
+ function liveIncludes(live, sha, mainRef = resolveMainRef()) {
123
+ const want = typeof sha === 'string' && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.toLowerCase() : null;
124
+ if (!live || !want || !/^(origin\/main|HEAD)$/.test(mainRef)) return null;
125
+ const liveCommit = typeof live.commit === 'string' && /^[0-9a-f]{40}$/i.test(live.commit) ? live.commit : null;
126
+ if (liveCommit && git(`cat-file -e ${liveCommit}`) !== null) {
127
+ return git(`merge-base --is-ancestor ${want} ${liveCommit}`) !== null;
128
+ }
129
+ if (!isPlausibleVersion(live.coreVersion)) return null;
130
+ if (git(`merge-base --is-ancestor ${want} ${mainRef}`) === null) return null; // not on main here yet
131
+ // `sha` itself may be the bump (a hand-cut release); otherwise the first bump after it.
132
+ const selfBump = git(`log -1 --format=%H -G"^const CORE_VERSION = " ${want} -- src/module-api.js`, '');
133
+ const after = git(`log --ancestry-path --reverse --format=%H -G"^const CORE_VERSION = " ${want}..${mainRef} -- src/module-api.js`, '');
134
+ const carrier = selfBump && selfBump.startsWith(want) ? selfBump : (after ? after.split('\n')[0].trim() : null);
135
+ if (!carrier) return false; // on main, but no release carries it yet
136
+ const carried = coreVersionAt(carrier);
137
+ return carried ? cmpSemver(live.coreVersion, carried) >= 0 : null;
138
+ }
139
+
140
+ // The sanitised reading the merge-reach sentence is built from. `live` may be a
141
+ // raw /version body (ship-finish passes one), so it goes through the same filters
142
+ // fetchLive applies before anything is interpolated.
143
+ // Prefer origin/main — the released trunk — but fall back to HEAD in a checkout with
144
+ // no remote (a box, a tarball). Shared by check() and hubReading() so both agree.
145
+ function resolveMainRef() {
146
+ return git('rev-parse --verify --quiet origin/main') ? 'origin/main' : 'HEAD';
147
+ }
148
+
149
+ function hubReading({ target, live, sha, mainRef = resolveMainRef() } = {}) {
150
+ return {
151
+ host: safeHost(target || API_BASE),
152
+ liveShort: live ? safeCommitShort(live.commitShort) || safeCommitShort(typeof live.commit === 'string' ? live.commit.slice(0, 8) : null) : null,
153
+ includes: live ? liveIncludes(live, sha, mainRef) : null,
154
+ };
155
+ }
156
+
102
157
  function cmpSemver(a, b) {
103
158
  const pa = String(a).split('.').map(Number);
104
159
  const pb = String(b).split('.').map(Number);
@@ -162,6 +217,8 @@ async function fetchLive(base, timeoutMs = DEFAULT_TIMEOUT_MS) {
162
217
  url,
163
218
  coreVersion: String(body.coreVersion),
164
219
  commitShort: safeCommitShort(body.commitShort),
220
+ // Full sha, hex-or-nothing: only ever handed to git (liveIncludes), never printed.
221
+ commit: typeof body.commit === 'string' && /^[0-9a-f]{40}$/i.test(body.commit) ? body.commit : null,
165
222
  schemaPending: safeCount(body.schemaPending),
166
223
  };
167
224
  } catch (e) {
@@ -193,7 +250,9 @@ function subscriptionHealth(dir = REPO_ROOT) {
193
250
  }
194
251
  }
195
252
 
196
- async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
253
+ // withReach: compute the who-sees-a-merge reading (a few git calls). The SessionStart
254
+ // --line never prints it, so it passes false and pays nothing for it.
255
+ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS, withReach = true } = {}) {
197
256
  const target = base || arg('--url') || API_BASE;
198
257
  const local = coreVersionAt(null);
199
258
  if (!local) return { ok: false, undetermined: true, reason: 'could not read CORE_VERSION from src/module-api.js', target };
@@ -201,7 +260,7 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
201
260
  // Prefer origin/main — the released trunk — but fall back to HEAD in a
202
261
  // checkout with no remote (a box, a tarball) so the check still says
203
262
  // something useful instead of bailing.
204
- const mainRef = git('rev-parse --verify --quiet origin/main') ? 'origin/main' : 'HEAD';
263
+ const mainRef = resolveMainRef();
205
264
  const onMain = coreVersionAt(mainRef) || local;
206
265
  const unreleased = unreleasedSince(mainRef);
207
266
  const live = await fetchLive(target, timeoutMs);
@@ -214,10 +273,12 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
214
273
  const ahead = cmpSemver(live.coreVersion, onMain) > 0;
215
274
  const hasUnreleased = !!(unreleased && unreleased.count > 0);
216
275
  const schemaBehind = live.schemaPending > 0;
276
+ // "this merge", read from here, is main's tip: ship-finish runs this right after a land.
277
+ const reach = withReach ? hubReading({ target, live, sha: git(`rev-parse ${mainRef}`), mainRef }) : null;
217
278
 
218
279
  return {
219
280
  ok: !behind && !hasUnreleased && !schemaBehind,
220
- target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, schemaBehind, subscriptions,
281
+ target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, schemaBehind, subscriptions, reach,
221
282
  };
222
283
  }
223
284
 
@@ -243,9 +304,16 @@ function report(r, { quiet = false } = {}) {
243
304
  }
244
305
  say('');
245
306
 
307
+ // Who sees a merge — the same two lines the ship card prints (merge-reach.js).
308
+ const reachBlock = () => {
309
+ say('');
310
+ for (const l of whoSeesAMerge(r.reach || { host: safeHost(r.target) })) say(` ${l}`);
311
+ say('');
312
+ };
313
+
246
314
  if (r.ok) {
247
315
  say(' ✓ aligned — live matches main, and main carries no unreleased commits.');
248
- say('');
316
+ reachBlock();
249
317
  return;
250
318
  }
251
319
 
@@ -257,13 +325,13 @@ function report(r, { quiet = false } = {}) {
257
325
  const merges = u.countWithMerges != null && u.countWithMerges !== u.count
258
326
  ? ` (${u.countWithMerges} counting merges)` : '';
259
327
  say(` ⚠ UNRELEASED — ${u.count} work commit${u.count === 1 ? '' : 's'}${merges} on ${r.mainRef} since the last CORE_VERSION bump.`);
260
- say(' No instance can take these at ANY pin: no published version contains them.');
261
- say(` Fix: bump CORE_VERSION in src/module-api.js (+ an entry in docs/module-api-changelog.md) and land it.`);
328
+ say(' No hosted project can take these at ANY pin: no published version contains them yet.');
329
+ say(' The publish lane (ADR 0161) normally carries a merge within minutes; a gap that stays means it is disarmed or red.');
262
330
  }
263
331
  if (r.behind) {
264
332
  say(` ⚠ UNDEPLOYED — live is ${r.live.coreVersion}, main declares ${r.onMain}.`);
265
- say(' A release exists that this instance has not moved its pin to.');
266
- say(' Fix: package-core, then move the instance pin (see docs/recipes/upgrading-the-core.md).');
333
+ say(' A release exists that this instance has not picked up. On the hub, its redeploy is late or stuck;');
334
+ say(' on a hosted project, its pin has not moved (see docs/recipes/upgrading-the-core.md).');
267
335
  }
268
336
  if (r.schemaBehind) {
269
337
  const n = r.live.schemaPending;
@@ -281,10 +349,7 @@ function report(r, { quiet = false } = {}) {
281
349
  say(` ${r.subscriptions.level === 'alarm' ? '⚠ AUTO-UPGRADE' : 'note: auto-upgrade'} — ${r.subscriptions.message}`);
282
350
  if (r.behind) say(' So the gap above will NOT close by itself.');
283
351
  }
284
- say('');
285
- say(' Merging to main deploys nothing by design. "shipped" means "merged" —');
286
- say(' it does not mean users can see it.');
287
- say('');
352
+ reachBlock();
288
353
  }
289
354
 
290
355
  // ONE line, for injecting into an agent's SessionStart context (task 1002713).
@@ -324,7 +389,8 @@ function line(r) {
324
389
  if (!parts.length) return null;
325
390
 
326
391
  const advice = [];
327
- if (versionParts) advice.push('Merging to main deploys nothing — do not claim work is visible to users until the pin moves.');
392
+ // Fixed vocabulary + the bounded host only (this line is injected context).
393
+ if (versionParts) advice.push(`${hubRule(host)}; ${HOSTED_LINE.charAt(0).toLowerCase()}${HOSTED_LINE.slice(1)} Say which one you mean before calling work live.`);
328
394
  if (pending) advice.push('Run migrate against that instance DB (PGDATABASE set) before anything else.');
329
395
  return `Deploy state (read from ${host}/version): ${parts.join('; ')}. ${advice.join(' ')}`;
330
396
  }
@@ -350,7 +416,7 @@ async function main() {
350
416
  // seconds apart asked this same question four times and all four timed out, which
351
417
  // emits NO line — and silence from a truth-teller reads as "nothing to report".
352
418
  const { cached } = require('./probe-cache.js');
353
- const { value: one } = await cached('deploy-divergence-line', async () => line(await check({ timeoutMs: ms })));
419
+ const { value: one } = await cached('deploy-divergence-line', async () => line(await check({ timeoutMs: ms, withReach: false })));
354
420
  if (one) console.log(one);
355
421
  } catch { /* offline, no git, unreadable module-api — all silence */ }
356
422
  process.exitCode = 0;
@@ -377,4 +443,4 @@ if (require.main === module) {
377
443
  });
378
444
  }
379
445
 
380
- module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth };
446
+ module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth, liveIncludes, hubReading };
@@ -147,9 +147,14 @@ function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return pa
147
147
  // This is a string question, not a filesystem one: the vendored layout is the only
148
148
  // way a core ends up under `node_modules/`, so the suffix IS the answer. Keeping it
149
149
  // pure means the control-plane path is testable without a box.
150
+ // Either separator counts as a segment boundary (task 1004663): a suffix built from
151
+ // path.sep alone never matched a POSIX-shaped path on Windows, so the suite went red
152
+ // there while the Linux box (the only place this runs for real) stayed green.
150
153
  function selfInstanceRoot(repoRoot = REPO_ROOT) {
151
- const suffix = path.sep + CORE_PKG_DIR.split('/').join(path.sep);
152
- return repoRoot.endsWith(suffix) ? repoRoot.slice(0, -suffix.length) : repoRoot;
154
+ const segs = CORE_PKG_DIR.split('/');
155
+ const tail = repoRoot.split(/[\\/]/).slice(-(segs.length + 1));
156
+ const isVendored = tail.length === segs.length + 1 && segs.every((s, i) => tail[i + 1] === s);
157
+ return isVendored ? repoRoot.slice(0, -(segs.join('/').length + 1)) : repoRoot;
153
158
  }
154
159
  // Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
155
160
  // step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
@@ -16,7 +16,7 @@ const { syncMemoryOnShip } = require('./ship-memory-sync.js');
16
16
  const { uploadSessionOnShip } = require('./ship-session-upload.js');
17
17
  const { execFileSync } = require('node:child_process');
18
18
  const { gitOk } = require('./ship-git.js');
19
- const { fetchDeployedVersion } = require('./ship-deploy-target.js');
19
+ const { fetchDeployedVersion, resolveInstanceBase } = require('./ship-deploy-target.js');
20
20
  const { landBailed, reconciledCardStatus } = require('./ship-land.js');
21
21
  const { autoMerge, publishHeld } = require('./ship-merge.js');
22
22
  const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
@@ -282,9 +282,17 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
282
282
 
283
283
  // Deployed commit (shipped only): prefer prod /version, fall back to short HEAD.
284
284
  let commit = null;
285
+ let reach = null;
285
286
  if (postGradeStatus === 'shipped') {
286
287
  const v = fetchDeployedVersion();
287
288
  commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
289
+ // task 1004682: say WHO sees this merge — the hub's live build (read, not
290
+ // assumed) and the hosted-projects rule — in the merge-reach wording.
291
+ try {
292
+ reach = require('./deploy-divergence').hubReading({
293
+ target: resolveInstanceBase(), live: v, sha: gitOk('git rev-parse HEAD'),
294
+ });
295
+ } catch (_) { /* the card falls back to the bare commit */ }
288
296
  }
289
297
 
290
298
  // Running drachmae total + the builder's card-richness level (task 1279's
@@ -342,7 +350,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
342
350
  grade: card.grade || null,
343
351
  credits: { awarded: card.creditsAwarded, total, perTaskCredit: rewardPolicy(getServerReward()).perTaskCredit },
344
352
  usage: { tokens, costUsd, newTokens, cachedTokens },
345
- deploy: { commit },
353
+ deploy: { commit, reach },
346
354
  // task 1002541: absent (the default) ⇒ the card behaves exactly as before,
347
355
  // which keeps every other caller — resumeMerge, the claim card, and
348
356
  // land-watch replaying breadcrumbs written by older versions — unchanged.
@@ -376,12 +384,13 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
376
384
  /* a summary card must never fail a ship */
377
385
  }
378
386
 
379
- // task 1002521: the card above says SHIPPED, which means "merged" — it does
380
- // NOT mean users can see it. Publishing is manual and owner-gated, so a ship
381
- // can land on main and change nothing live, and until now nothing said so.
382
- // Blocker 1000097: 171 commits (a whole governance system) sat unreleased for
383
- // ten days while every layer reported success. Warn, never block — a shipped
384
- // task is still shipped, and an offline builder must not be punished for it.
387
+ // task 1002521: the card above says SHIPPED, which means "merged". Its Deploy
388
+ // row says who sees that (task 1004682: the hub redeploys on every merge;
389
+ // hosted projects when a release carries it and their pin moves). This adds
390
+ // the full divergence report when live and main disagree — blocker 1000097:
391
+ // 171 commits sat unreleased for ten days while every layer reported success.
392
+ // Warn, never block — a shipped task is still shipped, and an offline builder
393
+ // must not be punished for it.
385
394
  try {
386
395
  const { check, report } = require('./deploy-divergence');
387
396
  const r = await check();
@@ -139,7 +139,7 @@ async function shipMain() {
139
139
  const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
140
140
  if (action.action === 'resume-grade') {
141
141
  console.log(`Task #${taskId} is at 'completed' (grade stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the grade (no re-claim needed).`);
142
- return regradeMain();
142
+ return regradeMain({ visual });
143
143
  }
144
144
  if (action.action === 'resume-merge') {
145
145
  console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
@@ -822,12 +822,12 @@ async function persistRegradeSummary({ taskId, summaryArg, task }) {
822
822
  // promotes completed→confirmed + awards credits on pass, idempotent)
823
823
  // - on pass: the normal confirmed→shipped merge + sync tail runs.
824
824
 
825
- async function regradeMain() {
825
+ async function regradeMain(opts = {}) {
826
826
  await requireSession();
827
827
  const args = process.argv.slice(2);
828
828
  const taskId = Number(args[0]);
829
829
  if (!Number.isFinite(taskId)) {
830
- console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
830
+ console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--visual <image>] [--visual-alt "caption"] [--no-merge] [--force] [--verified] [--base <ref>]');
831
831
  process.exit(2);
832
832
  }
833
833
  // task 1003702: refuse --notes rather than swallowing it. The reasoning — why
@@ -840,6 +840,16 @@ async function regradeMain() {
840
840
  for (const line of regradeNotesRefusal(taskId)) console.error(line);
841
841
  process.exit(2);
842
842
  }
843
+ // task 1004665: --visual used to be screened by shipMain and then dropped here
844
+ // without a word — this path returned before shipMain's upload, so visual_url
845
+ // stayed null (task 1004351). Screen it at the same early position as the
846
+ // --notes refusal (bad input costs no grade spend), and attach it on every
847
+ // path below that goes on to grade or land. shipMain's resume-grade dispatch
848
+ // hands over the visual it already screened rather than reading the file twice.
849
+ const visual = opts.visual !== undefined ? opts.visual : shipVisual.fromArgs(args, { regrade: true });
850
+ const attachVisual = async () => (visual
851
+ ? shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine })
852
+ : false);
843
853
  const regradeProse = resolveProse(args, ['summary', 'response']);
844
854
  const summaryArg = regradeProse.summary;
845
855
  // task 1002662: the builder's response to the prior round's findings — how
@@ -908,6 +918,7 @@ async function regradeMain() {
908
918
  // only re-publishes the branch (the recovery for a publish that failed).
909
919
  if (tweakHold.isHeldForArtist(task, tRes.data.grade ? tRes.data.grade.passed : null)) {
910
920
  console.log(`Task #${taskId} already passed its grade and is waiting for the artist: re-publishing its branch, not re-grading.`);
921
+ await attachVisual();
911
922
  await finishShip({
912
923
  taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
913
924
  card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
@@ -989,6 +1000,7 @@ async function regradeMain() {
989
1000
  console.log('');
990
1001
  console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
991
1002
  console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
1003
+ await attachVisual();
992
1004
  // A kind='verify' task cannot reach here without --verified: the gate above
993
1005
  // exits first, so the server's verify_attestation_required can't fire below.
994
1006
  const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
@@ -1060,6 +1072,9 @@ async function regradeMain() {
1060
1072
  // the panel runs afterwards. The row, the grader prompt, the merge commit and
1061
1073
  // the PR then all carry the same sentence.
1062
1074
  await persistRegradeSummary({ taskId, summaryArg, task });
1075
+ // task 1004665: the picture goes up at the same point and for the same reason
1076
+ // as the summary — after every guard that can still refuse, before the panel.
1077
+ await attachVisual();
1063
1078
 
1064
1079
  // ---- Delta-aware prior-round context (task 1002662) ----
1065
1080
  // The prior grade's findings + the builder's response + the interim fix diff
@@ -28,17 +28,24 @@ const taskVisuals = require('../../modules/lifecycle/task-visuals');
28
28
  // EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
29
29
  // costs the builder a retyped command, not a claim. Pure enough to reason about;
30
30
  // the exit is the deliberate CLI edge.
31
- function fromArgs(args) {
31
+ //
32
+ // `regrade: true` is the `ship.js <id> --regrade` caller (task 1004665). There is
33
+ // no claim left to resolve on that path, so the wording names what IS true there:
34
+ // the task stays at completed, and the picture goes up before the grade runs.
35
+ function fromArgs(args, { regrade = false } = {}) {
32
36
  const file = arg('--visual', args) || '';
33
37
  if (!file) return null;
34
38
  const read = taskVisuals.readImageFile(file);
35
39
  if (!read.ok) {
36
- console.error(`Ship aborted: --visual ${file} — ${read.message}`);
37
- console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
40
+ console.error(`${regrade ? 'Re-grade' : 'Ship'} aborted: --visual ${file} — ${read.message}`);
41
+ console.error(regrade
42
+ ? 'Fix the path (or drop --visual: a visual is always optional). Nothing was graded; the task stays at completed.'
43
+ : 'Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
38
44
  process.exit(2);
39
45
  }
40
46
  const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
41
- console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
47
+ const when = regrade ? 'uploaded before the re-grade runs' : 'uploaded after the claim resolves';
48
+ console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — ${when}.`);
42
49
  return visual;
43
50
  }
44
51
 
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.102'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.104'; // 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');
@@ -657,6 +657,14 @@ await t('a path that merely CONTAINS the package name is not mistaken for one',
657
657
  assert.equal(RP.selfInstanceRoot('/srv/node_modules/@bongos/core/sub'), '/srv/node_modules/@bongos/core/sub');
658
658
  });
659
659
 
660
+ await t('either path separator is a segment boundary, on any host OS (task 1004663)', () => {
661
+ // The suffix used to be built from path.sep alone, so the POSIX-shaped fixture
662
+ // above failed on Windows. Pin the other shape too, so Linux CI covers it.
663
+ assert.equal(RP.selfInstanceRoot('C:\\srv\\inst\\node_modules\\@bongos\\core'), 'C:\\srv\\inst');
664
+ assert.equal(RP.selfInstanceRoot('C:\\srv\\node_modules\\@bongos\\core-tools'), 'C:\\srv\\node_modules\\@bongos\\core-tools');
665
+ assert.equal(RP.selfInstanceRoot('/srv/other_modules/@bongos/core'), '/srv/other_modules/@bongos/core');
666
+ });
667
+
660
668
  await t('the runner reads the installed version from the INSTANCE, not from the core package', async () => {
661
669
  // The defect this replaces: readInstalledCoreVersion was handed REPO_ROOT, which in
662
670
  // the vendored shape holds no node_modules/@bongos/core at all — so it answered null
@@ -71,10 +71,16 @@ test('a behind instance names both versions and the host', () => {
71
71
  assert.equal(s.split('\n').length, 1, 'must be ONE line — it is injected context');
72
72
  });
73
73
 
74
- test('the line warns against claiming work is visible', () => {
75
- // The failure this exists to prevent is an agent SAYING a merged change is live.
76
- assert.match(line(behind), /Merging to main deploys nothing/);
77
- assert.match(line(behind), /visible to users/);
74
+ test('the line says who sees a merge, not a blanket "deploys nothing"', () => {
75
+ // task 1004682: the old advice ("Merging to main deploys nothing") was the ADR 0137
76
+ // rule, false since ADR 0161, and sessions repeated it to the owner about live work.
77
+ // The invariant now: name BOTH audiences — the hub redeploys on every merge, hosted
78
+ // projects only when a release carries the change and their pin moves.
79
+ const s = line(behind);
80
+ assert.doesNotMatch(s, /deploys nothing/);
81
+ assert.match(s, /cloudbongos[.]com redeploys from main on every merge/);
82
+ assert.match(s, /hosted projects: only when a published core release carries the change and their pin moves/);
83
+ assert.match(s, /Say which one you mean before calling work live/);
78
84
  });
79
85
 
80
86
  test('live AHEAD of main is reported, not treated as fine', () => {
@@ -273,14 +279,14 @@ test('a database missing migrations is named on the one line, with its own advic
273
279
  const s = line(schemaOnly);
274
280
  assert.match(s, /MISSING 3 migrations the running code ships/);
275
281
  assert.match(s, /Run migrate against that instance DB/);
276
- assert.doesNotMatch(s, /Merging to main deploys nothing/, 'the version advice is for version gaps only');
282
+ assert.doesNotMatch(s, /redeploys from main/, 'the version advice is for version gaps only');
277
283
  assert.equal(s.split('\n').length, 1);
278
284
  assert.match(line({ ...schemaOnly, live: { ...schemaOnly.live, schemaPending: 1 } }), /MISSING 1 migration the/);
279
285
 
280
286
  const both = line({ ...schemaOnly, behind: true, live: { version: '1.0.0', coreVersion: '1.19.56', schemaPending: 2 } });
281
287
  assert.match(both, /1\.19\.56/);
282
288
  assert.match(both, /MISSING 2 migrations/);
283
- assert.match(both, /Merging to main deploys nothing/);
289
+ assert.match(both, /redeploys from main on every merge/);
284
290
  });
285
291
 
286
292
  test('a non-integer schemaPending never reaches the line', () => {
@@ -316,3 +322,76 @@ test('check() reads schemaPending off /version, and only a bounded integer count
316
322
  assert.equal(bad.live.schemaPending, null);
317
323
  assert.equal(bad.schemaBehind, false);
318
324
  });
325
+
326
+ // ---- who sees a merge (task 1004682) ----------------------------------------
327
+ // liveIncludes answers "does the live build carry this commit?" two ways: plain
328
+ // ancestry when the /version commit is one this repo has, else by the first
329
+ // CORE_VERSION bump at or after the commit (the hub runs its own repo and reports
330
+ // its own commit). Pinned against this repo's real history, on HEAD so a checkout
331
+ // without origin/main still runs it (CI checks out fetch-depth 0).
332
+ const { liveIncludes, hubReading, coreVersionAt } = require('../scripts/gds/deploy-divergence.js');
333
+ const gitOut = (args) => spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' }).stdout.trim();
334
+ const bumps = gitOut(['log', 'HEAD', '-3', '--format=%H', '-G^const CORE_VERSION = ', '--', 'src/module-api.js']).split('\n').filter(Boolean);
335
+
336
+ test('liveIncludes reads inclusion by release when the live commit is not in this repo', { skip: bumps.length < 2 && 'needs two bumps in history' }, () => {
337
+ const [newer, older] = bumps;
338
+ const newerV = coreVersionAt(newer);
339
+ const olderV = coreVersionAt(older);
340
+ assert.equal(liveIncludes({ coreVersion: newerV }, newer, 'HEAD'), true, 'the release that IS the commit carries it');
341
+ assert.equal(liveIncludes({ coreVersion: olderV }, newer, 'HEAD'), false, 'an older release does not');
342
+ assert.equal(liveIncludes({ coreVersion: olderV }, older, 'HEAD'), true);
343
+ // A commit between the two bumps is carried by the NEWER one only.
344
+ const between = gitOut(['rev-list', '--max-count=1', '--no-merges', `${older}..${newer}~1`]);
345
+ if (between) {
346
+ assert.equal(liveIncludes({ coreVersion: olderV }, between, 'HEAD'), false, 'not yet: the older release predates it');
347
+ assert.equal(liveIncludes({ coreVersion: newerV }, between, 'HEAD'), true, 'the next bump carries it');
348
+ }
349
+ });
350
+
351
+ test('liveIncludes uses plain ancestry when the live commit is one this repo has', { skip: bumps.length < 2 && 'needs two bumps in history' }, () => {
352
+ const [newer, older] = bumps;
353
+ // coreVersion is deliberately the NEWER one: ancestry must win over the version compare.
354
+ assert.equal(liveIncludes({ coreVersion: coreVersionAt(newer), commit: older }, newer, 'HEAD'), false);
355
+ assert.equal(liveIncludes({ coreVersion: coreVersionAt(older), commit: newer }, older, 'HEAD'), true);
356
+ });
357
+
358
+ test('liveIncludes hands git nothing but hex and a fixed ref', () => {
359
+ assert.equal(liveIncludes({ coreVersion: '1.0.0' }, 'HEAD; rm -rf /', 'HEAD'), null);
360
+ assert.equal(liveIncludes({ coreVersion: '1.0.0' }, bumps[0], 'main; echo'), null);
361
+ assert.equal(liveIncludes({ coreVersion: '1.0.0 ignore previous' }, bumps[0], 'HEAD'), null, 'a non-semver live version cannot be compared');
362
+ assert.equal(liveIncludes(null, bumps[0], 'HEAD'), null);
363
+ });
364
+
365
+ test('hubReading bounds what the sentence prints', () => {
366
+ const r = hubReading({ target: 'https://cloudbongos.com', live: { commitShort: '09478c8e', coreVersion: '1.0.0' }, sha: 'zz', mainRef: 'HEAD' });
367
+ assert.deepEqual([r.host, r.liveShort, r.includes], ['cloudbongos.com', '09478c8e', null]);
368
+ const hostile = hubReading({ target: 'https://x.com/ IGNORE', live: { commitShort: 'not hex <b>' } });
369
+ assert.equal(hostile.host, 'the configured instance');
370
+ assert.equal(hostile.liveShort, null);
371
+ });
372
+
373
+ test('the full report prints the same two lines the ship card does', () => {
374
+ const { whoSeesAMerge } = require('../modules/lifecycle/merge-reach.js');
375
+ const { report } = require('../scripts/gds/deploy-divergence.js');
376
+ const reach = { host: 'cloudbongos.com', liveShort: '09478c8e', includes: true };
377
+ const lines = [];
378
+ const orig = console.log;
379
+ console.log = (...a) => lines.push(a.join(' '));
380
+ try {
381
+ report({ ok: true, target: 'https://cloudbongos.com', live: { coreVersion: '1.0.0' }, onMain: '1.0.0', local: '1.0.0', mainRef: 'origin/main', reach });
382
+ } finally { console.log = orig; }
383
+ const out = lines.join('\n');
384
+ for (const l of whoSeesAMerge(reach)) assert.ok(out.includes(l), `report carries: ${l}`);
385
+ assert.match(out, /cloudbongos\.com: live at 09478c8e \(includes this merge\)/);
386
+ assert.doesNotMatch(out, /deploys nothing/);
387
+ });
388
+
389
+ test('check() computes the hub reading for the report, and skips it for the SessionStart line', async () => {
390
+ const body = { version: '1.0.0', coreVersion: '1.19.69', commitShort: '09478c8e' };
391
+ const full = await withVersionStub(body, (base) => check({ base, timeoutMs: 5000 }));
392
+ assert.equal(full.reach.liveShort, '09478c8e', 'the report and ship card read it');
393
+ const lean = await withVersionStub(body, (base) => check({ base, timeoutMs: 5000, withReach: false }));
394
+ assert.equal(lean.reach, null, '--line never prints it, so it pays no git calls for it');
395
+ const src = fs.readFileSync(SCRIPT, 'utf8');
396
+ assert.match(src, /line\(await check\(\{ timeoutMs: ms, withReach: false \}\)\)/, 'the --line path opts out');
397
+ });
@@ -1357,23 +1357,26 @@ async function adoptWithPack(packText, mode = 'adopt') {
1357
1357
  }
1358
1358
  const U2 = require('../scripts/gds/provision-units.js');
1359
1359
 
1360
+ // The write path comes from path.join, so it carries `\` on Windows: every match below
1361
+ // takes either separator. Slash-only, the positive case failed there and the two
1362
+ // "left alone" cases passed vacuously (task 1004663).
1360
1363
  await ta('scaffoldStandaloneRepo(adopt): an owner pack on another envPrefix is corrected IN the adopt commit (task 1004573)', async () => {
1361
1364
  const owner = '{\n "identity": { "productName": "mercury" },\n "envPrefix": "CLOUDBONGOS",\n "configDir": "cloudbongos"\n}\n';
1362
1365
  const { seq, writes } = await adoptWithPack(owner);
1363
- const w = writes.find((x) => /config\/branding\.json$/.test(x.p));
1366
+ const w = writes.find((x) => /config[\\/]branding\.json$/.test(x.p));
1364
1367
  assert.ok(w, 'the pack was rewritten');
1365
1368
  assert.equal(JSON.parse(w.body).envPrefix, U2.provisionedEnvPrefix(), 'to the prefix web.env is written under');
1366
1369
  assert.equal(w.body, owner.replace('"envPrefix": "CLOUDBONGOS"', `"envPrefix": "${U2.provisionedEnvPrefix()}"`), 'and NOTHING else in the owner\'s file moved');
1367
- const wrote = seq.findIndex((s) => /^write .*config\/branding\.json$/.test(s));
1370
+ const wrote = seq.findIndex((s) => /^write .*config[\\/]branding\.json$/.test(s));
1368
1371
  const added = seq.findIndex((s) => /git -C \S+ add -A/.test(s));
1369
1372
  assert.ok(wrote >= 0 && added > wrote, 'written before `git add -A`, so it rides the adopt commit to the owner\'s repo');
1370
1373
  });
1371
1374
 
1372
1375
  await ta('scaffoldStandaloneRepo: a pack that already agrees, and a greenfield scaffold, are left alone (task 1004573)', async () => {
1373
1376
  const agree = await adoptWithPack(JSON.stringify({ envPrefix: U2.provisionedEnvPrefix() }));
1374
- assert.ok(!agree.writes.some((x) => /config\/branding\.json$/.test(x.p)), 'agreeing pack untouched');
1377
+ assert.ok(!agree.writes.some((x) => /config[\\/]branding\.json$/.test(x.p)), 'agreeing pack untouched');
1375
1378
  const green = await adoptWithPack(JSON.stringify({ envPrefix: 'CLOUDBONGOS' }), 'greenfield');
1376
- assert.ok(!green.writes.some((x) => /config\/branding\.json$/.test(x.p)), 'greenfield owns its config through init; no rewrite here');
1379
+ assert.ok(!green.writes.some((x) => /config[\\/]branding\.json$/.test(x.p)), 'greenfield owns its config through init; no rewrite here');
1377
1380
  });
1378
1381
 
1379
1382
  await ta('normalizePackEnvPrefix: rewrites only the value, refuses what it cannot rewrite safely (task 1004573)', async () => {
@@ -3499,10 +3502,16 @@ await ta('provisionInstance: the password IS written to web.env, which makeWrite
3499
3502
  // Driven through the REAL cp.execSync against a real failing shell, because the thing
3500
3503
  // being relied on is that Node populates e.stdout/e.stderr on a throw. A fake that
3501
3504
  // simply returns what the fix expects would prove nothing about that.
3505
+ //
3506
+ // The failing child is `node -e`, not `sh -c '…'` (task 1004663): on Windows execSync's
3507
+ // shell is cmd.exe, which does not honour single quotes, so it read the inner `1>&2` as
3508
+ // ITS redirect and sent the whole child's stdout to stderr — the stdout case failed
3509
+ // there, and the secret case's `stdout === ''` passed for that wrong reason.
3510
+ const failingChild = (out, err) => `"${process.execPath}" -e "process.stdout.write('${out}\\n');process.stderr.write('${err}\\n');process.exit(1)"`;
3502
3511
  t('makeExec(allowFail): a soft failure carries the command output back to the caller', () => {
3503
3512
  const logs = [];
3504
3513
  const run = P.makeExec({ apply: true, log: (m) => logs.push(m) });
3505
- const r = run(`sh -c 'echo "✖ already on 1.19.927"; echo "sudo: unknown user bongos-test4" 1>&2; exit 1'`, { allowFail: true });
3514
+ const r = run(failingChild('✖ already on 1.19.927', 'sudo: unknown user bongos-test4'), { allowFail: true });
3506
3515
  assert.equal(r.softFailed, true, 'still a soft failure');
3507
3516
  assert.equal(r.ok, true, 'and still ok:true — the caller decides, not the runner');
3508
3517
  assert.match(r.stdout, /already on 1\.19\.927/, 'stdout survives');
@@ -3515,7 +3524,7 @@ t('makeExec(allowFail): a SECRET-BEARING step still withholds its output', () =>
3515
3524
  // around that control on the return value instead of the log.
3516
3525
  const logs = [];
3517
3526
  const run = P.makeExec({ apply: true, log: (m) => logs.push(m) });
3518
- const r = run(`sh -c 'echo "token=abcd1234"; echo "token=abcd1234" 1>&2; exit 1'`, { allowFail: true, shown: 'npm publish (token redacted)' });
3527
+ const r = run(failingChild('token=abcd1234', 'token=abcd1234'), { allowFail: true, shown: 'npm publish (token redacted)' });
3519
3528
  assert.equal(r.softFailed, true);
3520
3529
  assert.equal(r.stdout, '', 'no output escapes a secret-bearing step');
3521
3530
  assert.ok(!/abcd1234/.test(JSON.stringify(r)), 'nothing token-shaped is on the result at all');
@@ -69,6 +69,24 @@ test('shipped model: rows, facts, action', () => {
69
69
  assert.equal(m.action.cmd, '/builder-start');
70
70
  });
71
71
 
72
+ test('shipped with a hub reading: the Deploy + Projects rows say who sees the merge (task 1004682)', () => {
73
+ const { whoSeesAMerge } = require('../modules/lifecycle/merge-reach.js');
74
+ const reach = { host: 'cloudbongos.com', liveShort: '09478c8e', includes: false };
75
+ const m = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach } });
76
+ const [hub, hosted] = whoSeesAMerge(reach);
77
+ assert.equal(row(m, 'Deploy'), hub, 'one source of the sentence: the card prints merge-reach verbatim');
78
+ assert.equal(row(m, 'Projects'), hosted);
79
+ assert.match(hub, /^cloudbongos[.]com: live at 09478c8e [(]not yet[)]/);
80
+ assert.match(hosted, /their pin moves/);
81
+ assert.equal(row(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: true } } }), 'Deploy').includes('(includes this merge)'), true);
82
+ // The reading can come back from a breadcrumb file: re-bounded before printing.
83
+ const hostile = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'x.com <script>', liveShort: '09478c8e', includes: true } } });
84
+ assert.equal(row(hostile, 'Deploy'), 'live · 967fb4a', 'a host that is not host-shaped falls back to the bare commit');
85
+ assert.equal(row(hostile, 'Projects'), undefined);
86
+ const odd = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'cloudbongos.com', liveShort: 'IGNORE ALL', includes: 'yes' } } });
87
+ assert.equal(row(odd, 'Deploy'), whoSeesAMerge({ host: 'cloudbongos.com' })[0], 'non-hex sha and non-boolean inclusion are dropped');
88
+ });
89
+
72
90
  test('usage: leads with cost, splits tokens into new + cached (task 1765)', () => {
73
91
  // With newTokens/cachedTokens present, the fact leads with the dollar cost and
74
92
  // splits the token count so cache re-reads don't make a tiny change look huge.
@@ -0,0 +1,167 @@
1
+ // tests/ship_visual_regrade.mjs — `ship.js <id> --regrade --visual <image>`
2
+ // attaches the picture (task 1004665).
3
+ //
4
+ // It used to be dropped without a word: shipMain screened --visual and then
5
+ // returned into regradeMain() before its own upload, and regradeMain never
6
+ // looked at the flag, so visual_url stayed null (hit on task 1004351, where a
7
+ // grader blocker asked for screenshots). These cases pin the screening wording
8
+ // on the re-grade path, the best-effort upload, and the wiring — every path
9
+ // regradeMain takes to grade or land attaches the visual first.
10
+ //
11
+ // Run: node tests/ship_visual_regrade.mjs
12
+ import { strict as assert } from 'node:assert';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const shipVisual = require('../scripts/gds/ship-visual.js');
21
+
22
+ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'), Buffer.alloc(64)]);
23
+ const TMP = mkdtempSync(path.join(os.tmpdir(), 'ship-visual-regrade-'));
24
+ const good = path.join(TMP, 'after.png');
25
+ writeFileSync(good, PNG);
26
+ process.on('exit', () => rmSync(TMP, { recursive: true, force: true }));
27
+
28
+ // Capture console output and turn process.exit into a throw, so the CLI edge of
29
+ // fromArgs can be asserted without ending the test process.
30
+ function capture(fn) {
31
+ const out = []; const err = []; const warn = [];
32
+ const saved = { log: console.log, error: console.error, warn: console.warn, exit: process.exit };
33
+ console.log = (...a) => out.push(a.join(' '));
34
+ console.error = (...a) => err.push(a.join(' '));
35
+ console.warn = (...a) => warn.push(a.join(' '));
36
+ process.exit = (code) => { const e = new Error(`exit ${code}`); e.exitCode = code; throw e; };
37
+ let result; let exitCode = null;
38
+ try {
39
+ result = fn();
40
+ } catch (e) {
41
+ if (e.exitCode === undefined) throw e;
42
+ exitCode = e.exitCode;
43
+ } finally {
44
+ Object.assign(console, { log: saved.log, error: saved.error, warn: saved.warn });
45
+ process.exit = saved.exit;
46
+ }
47
+ return { result, exitCode, out: out.join('\n'), err: err.join('\n'), warn: warn.join('\n') };
48
+ }
49
+
50
+ // ---- screening ------------------------------------------------------------
51
+
52
+ test('no --visual means null on both paths, and nothing printed', () => {
53
+ for (const opts of [undefined, { regrade: true }]) {
54
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade'], opts));
55
+ assert.equal(r.result, null);
56
+ assert.equal(r.out + r.err, '');
57
+ }
58
+ });
59
+
60
+ test('on --regrade a good image is screened and says it goes up BEFORE the grade', () => {
61
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', good, '--visual-alt', 'the after'], { regrade: true }));
62
+ assert.equal(r.exitCode, null);
63
+ assert.equal(r.result.contentType, 'image/png');
64
+ assert.equal(r.result.alt, 'the after');
65
+ assert.match(r.out, /uploaded before the re-grade runs/);
66
+ assert.doesNotMatch(r.out, /claim resolves/, 'a re-grade has no claim left to resolve');
67
+ });
68
+
69
+ test('a normal ship keeps its own wording', () => {
70
+ const r = capture(() => shipVisual.fromArgs(['1', '--visual', good]));
71
+ assert.match(r.out, /uploaded after the claim resolves/);
72
+ });
73
+
74
+ test('on --regrade a bad path aborts BEFORE any grade, with re-grade wording', () => {
75
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', path.join(TMP, 'missing.png')], { regrade: true }));
76
+ assert.equal(r.exitCode, 2);
77
+ assert.match(r.err, /^Re-grade aborted: --visual/);
78
+ assert.match(r.err, /Nothing was graded; the task stays at completed/);
79
+ assert.doesNotMatch(r.err, /No claim resolved/);
80
+ });
81
+
82
+ // ---- upload ---------------------------------------------------------------
83
+
84
+ function fakeClient(response) {
85
+ const calls = [];
86
+ return {
87
+ calls,
88
+ tasks: { postTasksIdVisual: async (req) => { calls.push(req); return response; } },
89
+ };
90
+ }
91
+
92
+ test('upload posts the image to the task and reports it stuck', async () => {
93
+ const client = fakeClient({ ok: true, status: 201, data: {} });
94
+ const visual = { buf: PNG, contentType: 'image/png', alt: 'cap', name: 'after.png' };
95
+ const r = await (async () => {
96
+ const saved = console.log; const out = [];
97
+ console.log = (...a) => out.push(a.join(' '));
98
+ try { return { ok: await shipVisual.upload({ taskId: 7, visual, client, errorLine: () => '' }), out }; } finally { console.log = saved; }
99
+ })();
100
+ assert.equal(r.ok, true);
101
+ assert.equal(client.calls.length, 1);
102
+ assert.equal(client.calls[0].id, 7);
103
+ assert.equal(client.calls[0].body.image_b64, PNG.toString('base64'));
104
+ assert.equal(client.calls[0].body.alt, 'cap');
105
+ });
106
+
107
+ test('a failed upload is a warning naming the manual re-attach, never a failure', async () => {
108
+ const client = fakeClient({ ok: false, status: 403, data: { error: 'nope' } });
109
+ const visual = { buf: PNG, contentType: 'image/png', alt: '', name: 'after.png' };
110
+ const saved = console.warn; const warn = [];
111
+ console.warn = (...a) => warn.push(a.join(' '));
112
+ let ok;
113
+ try { ok = await shipVisual.upload({ taskId: 7, visual, client, errorLine: (d, s) => `HTTP ${s}` }); } finally { console.warn = saved; }
114
+ assert.equal(ok, false);
115
+ assert.match(warn.join('\n'), /node scripts\/gds\/task\.js visual 7 <image>/);
116
+ });
117
+
118
+ test('no visual uploads nothing', async () => {
119
+ const client = fakeClient({ ok: true });
120
+ assert.equal(await shipVisual.upload({ taskId: 7, visual: null, client, errorLine: () => '' }), false);
121
+ assert.equal(client.calls.length, 0);
122
+ });
123
+
124
+ // ---- wiring ---------------------------------------------------------------
125
+
126
+ const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
127
+ const stripComments = (s) => s.replace(/^\s*\/\/[^\n]*$/gm, '');
128
+ const regrade = stripComments(src.slice(src.indexOf('async function regradeMain'), src.indexOf('module.exports')));
129
+ const ship = stripComments(src.slice(src.indexOf('async function shipMain'), src.indexOf('async function regradeMain')));
130
+
131
+ test('regradeMain screens --visual early, before the preflight and the panel', () => {
132
+ const screen = regrade.indexOf('shipVisual.fromArgs(args, { regrade: true })');
133
+ assert.ok(screen !== -1, 'regradeMain screens the flag itself');
134
+ assert.ok(screen < regrade.indexOf('preflight(claimLike'), 'before the preflight');
135
+ assert.ok(screen < regrade.indexOf('gradeAndRecord('), 'before any grade spend');
136
+ });
137
+
138
+ test('every path regradeMain takes to grade or land attaches the visual first', () => {
139
+ // Three ways out that do real work: the held re-publish, the artifact-free
140
+ // confirm, and the graded path. Each finishShip must be preceded by an attach
141
+ // that sits AFTER the previous finishShip — i.e. one attach per path.
142
+ const finishes = [...regrade.matchAll(/await finishShip\(/g)].map((m) => m.index);
143
+ const attaches = [...regrade.matchAll(/await attachVisual\(\)/g)].map((m) => m.index);
144
+ assert.equal(finishes.length, 3, 'three landing paths (update this pin if one is added)');
145
+ assert.equal(attaches.length, 3, 'one attach per landing path');
146
+ let prev = -1;
147
+ finishes.forEach((f, i) => {
148
+ assert.ok(attaches[i] > prev && attaches[i] < f, `landing path ${i + 1} attaches before it lands`);
149
+ prev = f;
150
+ });
151
+ // The graded path attaches before the panel runs, after the summary persists.
152
+ const graded = attaches[2];
153
+ assert.ok(regrade.indexOf('await persistRegradeSummary(') < graded, 'after every guard that can refuse');
154
+ assert.ok(graded < regrade.indexOf('gradeAndRecord('), 'before the grade');
155
+ // The artifact-free path attaches before the confirm call lands it.
156
+ assert.ok(attaches[1] < regrade.indexOf('postTasksIdConfirm('), 'before the manual confirm');
157
+ });
158
+
159
+ test('a plain re-ship that resumes the grade hands over the visual it screened', () => {
160
+ assert.match(ship, /return regradeMain\(\{ visual \}\)/);
161
+ assert.match(regrade, /opts\.visual !== undefined \? opts\.visual : shipVisual\.fromArgs/);
162
+ });
163
+
164
+ test('the re-grade usage line advertises --visual', () => {
165
+ const usage = regrade.slice(0, regrade.indexOf('process.exit(2)'));
166
+ assert.match(usage, /--regrade [^\n]*--visual <image>/);
167
+ });