@bongos/core 1.19.994 → 1.19.996

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.994",
6
- "core_contract": "1.19.994",
7
- "source_commit": "2d0c99af93af0f1d1728f6fce1dbce057f209d4c",
5
+ "core_version": "1.19.996",
6
+ "core_contract": "1.19.996",
7
+ "source_commit": "5929da0385a4022562ebd43a5a71d81400e45836",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T22:40:55.441Z",
9
+ "built_at": "2026-09-23T23:40:05.075Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 519,
12
+ "docs_redacted": 520,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2404,
14
+ "functional_verbatim": 2405,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2949,
20
- "tree_sha256": "4b408d832b68004c07a3d8ad8143cc76a7a5c40c651ba47878f8ae27230568c6",
19
+ "file_count": 2951,
20
+ "tree_sha256": "cd26f1916edf3136e53591dc18c37ed45254dd805880bf4b25bc1af1c59baf93",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2717,7 +2717,7 @@
2717
2717
  {
2718
2718
  "path": "docs/module-api-changelog.md",
2719
2719
  "mode": "0000644",
2720
- "sha256": "aff2b4b93d88dd46eb74dacd0d51c4a959d1a860b9fda1646f3bd2c842780885"
2720
+ "sha256": "950ea34b2bb315e54fc02aa22753b3370ee009ab06727238002aa3a54e78782e"
2721
2721
  },
2722
2722
  {
2723
2723
  "path": "docs/modules-contract.md",
@@ -2924,6 +2924,11 @@
2924
2924
  "mode": "0000644",
2925
2925
  "sha256": "f6d2bb1da53f03f54080b8e0aec9f63390d3343c32917b64225ac47ae4ba7b42"
2926
2926
  },
2927
+ {
2928
+ "path": "docs/recipes/render-pilot.md",
2929
+ "mode": "0000644",
2930
+ "sha256": "7b5f391285931a5f5e1bd31683702d534e9876d55ce5a0794e7d8eeec52fc2f3"
2931
+ },
2927
2932
  {
2928
2933
  "path": "docs/recipes/reranking-a-goals-board.md",
2929
2934
  "mode": "0000644",
@@ -5502,7 +5507,7 @@
5502
5507
  {
5503
5508
  "path": "modules/hall-ui/public/work.css",
5504
5509
  "mode": "0000644",
5505
- "sha256": "68399594a00fdffaf9833d3105d8c9e2f5872642b0f49868edae6cc526fa9021"
5510
+ "sha256": "12b5c9aefa39b8922a5a5a8585a107779bc15678b120cf00239584e28959c604"
5506
5511
  },
5507
5512
  {
5508
5513
  "path": "modules/hall-ui/public/work.html",
@@ -5512,7 +5517,7 @@
5512
5517
  {
5513
5518
  "path": "modules/hall-ui/public/work.js",
5514
5519
  "mode": "0000644",
5515
- "sha256": "ccf5a2bad033e97bf0a3ed1105beac415df9191afd9d5778185ef6da5caee3a9"
5520
+ "sha256": "b645f234ad549dc245a3a4f066b841ddfa6888a619db8ce3816f8e91b3af7a17"
5516
5521
  },
5517
5522
  {
5518
5523
  "path": "modules/hall-ui/public/work.states.json",
@@ -8182,12 +8187,12 @@
8182
8187
  {
8183
8188
  "path": "package-lock.json",
8184
8189
  "mode": "0000644",
8185
- "sha256": "886b77d3e78846e2ac7160ee992c62370feca07cedeac66ae1e71c0f4723f5a7"
8190
+ "sha256": "6d4291f9e503ac170998451d6607d15f831d39cd43aaa2443ada021e10386f13"
8186
8191
  },
8187
8192
  {
8188
8193
  "path": "package.json",
8189
8194
  "mode": "0000644",
8190
- "sha256": "c6b01ed3391c36e6a05936826c2926683568412b7d3b15d50deeeb5e970dce15"
8195
+ "sha256": "50946900cdbd241bfe7a8ac94d4b50b65a9adb622f2debdd2048354a2ead38e4"
8191
8196
  },
8192
8197
  {
8193
8198
  "path": "public-docs/index.html",
@@ -10232,7 +10237,7 @@
10232
10237
  {
10233
10238
  "path": "src/module-api.js",
10234
10239
  "mode": "0000644",
10235
- "sha256": "bc208fde6a6f87de913925e670e21c54db09f8797b7e0a0f773b5d83d2fe200f"
10240
+ "sha256": "899926ec43a4f0087125deada1bf430aa5f34bd1b3f32ab65ff2ffea981431a3"
10236
10241
  },
10237
10242
  {
10238
10243
  "path": "src/module-loader/catalog.js",
@@ -14592,7 +14597,7 @@
14592
14597
  {
14593
14598
  "path": "tests/update_subscription_engine.mjs",
14594
14599
  "mode": "0000644",
14595
- "sha256": "a083174381f10d1cd19795b43580a3113c4f758e4b98da1f7c8e389429cb6a26"
14600
+ "sha256": "636c22e03dfa436c4cb30b59bdeb2d5085cb38e027b32dfc77cc000e4fd6569c"
14596
14601
  },
14597
14602
  {
14598
14603
  "path": "tests/upgrade.mjs",
@@ -14729,6 +14734,11 @@
14729
14734
  "mode": "0000644",
14730
14735
  "sha256": "2dc90311d8a4f31b543ed7ef711b4d3fc8b387a241bf0bbe797ab360107eac38"
14731
14736
  },
14737
+ {
14738
+ "path": "tests/work_title_wrap.mjs",
14739
+ "mode": "0000644",
14740
+ "sha256": "fb42c5fd94bcbd0c968ede7da4026604ff56eed2974be81b370fde08d9fea1df"
14741
+ },
14732
14742
  {
14733
14743
  "path": "tests/workflow_exec_paths.mjs",
14734
14744
  "mode": "0000644",
@@ -2462,5 +2462,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2462
2462
  landed since 1.19.992 with no explicit bump. run 35920872835. (task 1002620)
2463
2463
  1.19.994 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2464
2464
  landed since 1.19.993 with no explicit bump. run 35929622479. (task 1002620)
2465
+ 1.19.995 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2466
+ landed since 1.19.994 with no explicit bump. run 35933457545. (task 1002620)
2467
+ 1.19.996 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2468
+ landed since 1.19.995 with no explicit bump. run 35934673169. (task 1002620)
2465
2469
  ---------------------------------------------------------------------------
2466
2470
  ```
@@ -0,0 +1,218 @@
1
+ # Recipe — the Render pilot, on a throwaway `mercury` clone
2
+
3
+ The executable half of [task 1004142](https://cloudbongos.com/builders#/task/1004142): stand one Bongos instance up on Render, end to end, and answer the questions [task 1004183](https://cloudbongos.com/builders#/task/1004183) (the standup leg) will be written against. Governed by [ADR 0327](../adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md); platform choice from [ADR 0323](../adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md).
4
+
5
+ **Who runs this.** Whoever holds the Render key — under ADR 0327 that is the *project owner*, and for the pilot it is the platform owner. An agent session cannot run it unless someone has put a key in front of it, and a key must never be pasted into a chat.
6
+
7
+ **The API shape below was read from Render's own reference on 2026-09-23** (`api-docs.render.com`), not inferred. Where this recipe and a live response disagree, the response wins and this file is the bug.
8
+
9
+ ---
10
+
11
+ ## 0. Before you start — three things worth knowing
12
+
13
+ **The pilot costs money, and the free tier cannot prove it.** Done-when clause 4 is "its database is backed up by Render rather than by our timer". Render's **free Postgres has no backups of any kind** — no logical backups, no PITR. Only paid databases are continually backed up (3 days of recovery window on Hobby, 7 on Pro). So the pilot must run a **paid** Postgres or clause 4 is unprovable. Budget `0.1c-256mb` ($6/mo) + `0.5c-512mb` web ($7/mo), prorated for the hours the pilot is up, and tear it down when finished.
14
+
15
+ **The key is account-wide, not workspace-wide.** `GET /owners` returns every workspace the key's *user* can reach, including other people's teams. This is the finding that produced ADR 0327, and step 1 exists to confirm it on live responses before anything is created.
16
+
17
+ **Do not touch live `mercury`.** Row 31 is a real owner's project, serving from our box today. Clone its *repo* to a scratch repo under an account we control, and stand the clone up under a scratch slug. Nothing in this recipe writes to row 31, to `willHermesLines/mercury_bongos`, or to that owner's Render account.
18
+
19
+ Set up once:
20
+
21
+ ```bash
22
+ export RENDER_TOKEN=... # in your shell, not in a file, not in chat
23
+ export R="curl -sS -H \"Authorization: Bearer $RENDER_TOKEN\" -H 'Content-Type: application/json'"
24
+ ```
25
+
26
+ Base URL is `https://api.render.com/v1`; auth is `Authorization: Bearer <key>`.
27
+
28
+ ---
29
+
30
+ ## 1. The `ownerId` guard — confirm the finding BEFORE creating anything
31
+
32
+ This is the pilot's most important output, because every call the standup leg later writes depends on it.
33
+
34
+ ```bash
35
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" https://api.render.com/v1/owners | jq
36
+ ```
37
+
38
+ Each element is `{ owner: { id, name, email, type }, cursor }`, where `type` is `user` or `team`.
39
+
40
+ **Record:** how many workspaces come back, which are not ours, and their ids. Then pick the Bongos workspace's `id` and pin it:
41
+
42
+ ```bash
43
+ export RENDER_OWNER_ID=...
44
+ ```
45
+
46
+ **Then prove the hazard rather than assuming it.** List a resource *without* `ownerId` and check whether another workspace's resources appear:
47
+
48
+ ```bash
49
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" 'https://api.render.com/v1/services?limit=100' \
50
+ | jq '[.[].service | {id, name, ownerId}] | group_by(.ownerId) | map({ownerId: .[0].ownerId, n: length})'
51
+ ```
52
+
53
+ If more than one `ownerId` comes back from a key that was only ever meant for one workspace, the finding is confirmed and the guard is mandatory. **Record the actual output either way** — a negative result is just as useful and would narrow ADR 0327's §"required guard".
54
+
55
+ **The rule the standup leg must implement, whatever this shows:**
56
+
57
+ - Send an explicit `ownerId` on every call. It is *required* on `POST /services` and `POST /postgres`, and it is *optional* on the list calls — which is exactly where the hazard lives.
58
+ - Assert `ownerId` on the **response**, not the request. A request you got right tells you nothing about a response that came from elsewhere.
59
+ - Treat missing or mismatched as a hard error. Never default it, and never derive the workspace by taking the first element of `/owners`.
60
+
61
+ ---
62
+
63
+ ## 2. Clone the repo
64
+
65
+ `mercury`'s instance row carries `target_ref=willHermesLines/mercury_bongos` and `onboard_mode=adopt`. Clone it to a scratch repo you own — this is the pilot's subject and it is disposable.
66
+
67
+ ```bash
68
+ git clone https://github.com/willHermesLines/mercury_bongos /tmp/mercury-clone
69
+ cd /tmp/mercury-clone && git remote remove origin
70
+ gh repo create <you>/render-pilot --private --source=. --push
71
+ ```
72
+
73
+ **Record:** whether the repo is public or private, and under which account. That feeds the open question in step 5.
74
+
75
+ ---
76
+
77
+ ## 3. Create the Postgres
78
+
79
+ ```bash
80
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
81
+ https://api.render.com/v1/postgres -d '{
82
+ "name": "render-pilot-db",
83
+ "ownerId": "'"$RENDER_OWNER_ID"'",
84
+ "plan": "basic_256mb",
85
+ "region": "virginia",
86
+ "version": "16"
87
+ }' | jq
88
+ ```
89
+
90
+ `plan`, `ownerId`, `name` and `version` are all required. `databaseName` and `databaseUser` are generated if omitted. **`plan` must not be `free`** — see §0. Region `virginia` is the closest US region to the current droplet (`PROVISION_REGION=nyc3`).
91
+
92
+ Then read the connection string:
93
+
94
+ ```bash
95
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" \
96
+ https://api.render.com/v1/postgres/<postgresId>/connection-info | jq
97
+ ```
98
+
99
+ Returns `password`, `internalConnectionString`, `externalConnectionString`, `psqlCommand`, and optionally the pooled variants. **Assert the `ownerId` on the create response before going further.**
100
+
101
+ ---
102
+
103
+ ## 4. Create the web service
104
+
105
+ Bongos reads `DATABASE_URL` first and falls back to the `PG*` family (`src/bongos/pool.js:56`), so Render's connection string wires in directly — **none of the `PGHOST`/`PGDATABASE`/`PGUSER`/`PGPASSWORD` lines `webEnvBody` writes on the box are needed here.** That is the single biggest simplification Render buys, and it is worth stating in the standup leg's comments.
106
+
107
+ The env prefix is **`BONGOS`** (`config/branding.neutral.json` → `envPrefix`). The variables below are the Render translation of `webEnvBody` in `scripts/gds/provision-units.js:192` — read that function, not this list, if the two ever disagree.
108
+
109
+ ```bash
110
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
111
+ https://api.render.com/v1/services -d '{
112
+ "type": "web_service",
113
+ "name": "render-pilot",
114
+ "ownerId": "'"$RENDER_OWNER_ID"'",
115
+ "repo": "https://github.com/<you>/render-pilot",
116
+ "branch": "main",
117
+ "autoDeploy": "yes",
118
+ "serviceDetails": {
119
+ "runtime": "node",
120
+ "plan": "0.5c-512mb",
121
+ "region": "virginia",
122
+ "healthCheckPath": "/healthz",
123
+ "envSpecificDetails": {
124
+ "buildCommand": "npm ci",
125
+ "startCommand": "npm start"
126
+ }
127
+ },
128
+ "envVars": [
129
+ { "key": "DATABASE_URL", "value": "<internalConnectionString>" },
130
+ { "key": "BONGOS_GITHUB_CLIENT_ID", "value": "" },
131
+ { "key": "BONGOS_GITHUB_CLIENT_SECRET", "value": "" },
132
+ { "key": "BONGOS_FIRST_ADMIN", "value": "<your-github-login>" },
133
+ { "key": "BONGOS_PUBLIC_ORIGIN", "value": "https://render-pilot.onrender.com" },
134
+ { "key": "BONGOS_BUILDERS_ORIGIN", "value": "https://render-pilot.onrender.com" },
135
+ { "key": "BONGOS_STATUS_ORIGIN", "value": "https://render-pilot.onrender.com" },
136
+ { "key": "BONGOS_OAUTH_ORIGIN", "value": "https://render-pilot.onrender.com" }
137
+ ]
138
+ }' | jq
139
+ ```
140
+
141
+ Notes that matter:
142
+
143
+ - **`PORT` is not set by us.** Render injects it; `webEnvBody` writes it because the box allocates ports itself. One more line the standup leg drops.
144
+ - **The four origin overrides are load-bearing, not cosmetic.** Without them `src/branding.js` stays on neutral localhost and the hall never loads (task 1972). They must be the Render hostname, and they must all four be the same host — a single-host instance has no separate `builders.`/`status.` subdomain.
145
+ - **`BONGOS_FIRST_ADMIN` is what seats you as Archon** on first sign-in. Omit it and the instance has no owner and you can never claim it (the 2026-07-07 `demo` regression).
146
+ - **OAuth creds start blank** and are filled after the app exists — see step 5.
147
+ - `PGPOOL_MAX` is read bare and still applies. On `basic_256mb` the connection ceiling is low; if the app struggles to connect, set it small (the box uses `PROVISION_INSTANCE_PGPOOL_MAX`).
148
+ - Do **not** send a `secretFiles` entry for the GitHub App PEM until you know whether the App path is in scope for the pilot; blank OAuth is enough to prove boot.
149
+
150
+ ---
151
+
152
+ ## 5. Sign-in, and the open question the pilot exists to answer
153
+
154
+ Create a GitHub OAuth app (or App) whose callback is the Render hostname, then set `BONGOS_GITHUB_CLIENT_ID` / `_SECRET` via `PATCH /services/{id}/env-vars/{key}` and redeploy.
155
+
156
+ **The question ADR 0327 left for this pilot:** can Render's GitHub App reach a repo under a **personal** account rather than an org, and what does that mean for a project whose repo we do not own? Record:
157
+
158
+ - Which GitHub account/org the pilot repo sits under, and whether Render's GitHub App installed onto it without friction.
159
+ - Whether a *private* repo under someone else's personal account is reachable at all.
160
+ - What the owner has to click, since under ADR 0327 the repo and the Render account are both theirs.
161
+
162
+ This is the answer that decides whether `cloud-host` standup is one API sequence or a sequence plus a human step — and therefore how much friction `cloud-host` actually removes over `byo-host`.
163
+
164
+ ---
165
+
166
+ ## 6. Migrations
167
+
168
+ Run `migrate.sh` against the Render database. Prefer a Render **job** or the service's own shell over your laptop, so the run happens from inside Render's network on the `internalConnectionString`; the `externalConnectionString` works from outside but is not what production will use.
169
+
170
+ Then confirm no schema lag:
171
+
172
+ ```bash
173
+ curl -sS https://render-pilot.onrender.com/version | jq
174
+ ```
175
+
176
+ ---
177
+
178
+ ## 7. Custom domain (optional for the pilot, required for the model)
179
+
180
+ ADR 0323 keeps **Cloudflare DNS as the front door** and drops the Caddy site block, because Render terminates TLS. Prove the chain end to end at least once:
181
+
182
+ ```bash
183
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
184
+ https://api.render.com/v1/services/<serviceId>/custom-domains -d '{"name": "render-pilot.cloudbongos.com"}' | jq
185
+ ```
186
+
187
+ Then point the Cloudflare record at Render and confirm the response header is **no longer `via: 1.1 Caddy`** — that header is precisely how we established the live `mercury` instance is still on our box, and its absence is the cleanest proof the front door moved.
188
+
189
+ ---
190
+
191
+ ## 8. Capture the evidence — the five done-when clauses
192
+
193
+ Record each in the session log, with the actual output, not a claim that it worked:
194
+
195
+ | # | Clause | Evidence |
196
+ |---|---|---|
197
+ | 1 | Boots | `GET /healthz` → 200, and the header does **not** say `via: 1.1 Caddy` |
198
+ | 2 | Signs in | the OAuth round trip completes and seats you as Archon |
199
+ | 3 | Migrations run | `migrate.sh` completes; `/version` reports no schema lag |
200
+ | 4 | **Render backs up the DB** | a backup exists in Render's dashboard/API that we did not create — this is the clause that retires `provision-units.js`'s backup service + timer, and it needs a **paid** database |
201
+ | 5 | GitHub App reach | written answer to §5 |
202
+
203
+ Plus the three the standup leg is written from: the **exact call sequence** above with real request/response shapes, the **`ownerId` guard's live behaviour** from §1, and whether the **default plan sizes** held or mercury's larger choice was right.
204
+
205
+ ---
206
+
207
+ ## 9. Tear the clone down
208
+
209
+ The pilot is disposable and it is costing money. Delete the web service, delete the Postgres, delete the scratch repo, and remove the Cloudflare record if you made one.
210
+
211
+ **Note the asymmetry deliberately:** you are deleting resources in *your own* account, by hand. ADR 0327 decision 3 says the platform must never hold the ability to destroy infrastructure it does not own — `provision-teardown.js` retargets to *forgetting*, not to a Render delete call ([task 1004183](https://cloudbongos.com/builders#/task/1004183)). The fact that this step is manual is the design working, not a gap in it.
212
+
213
+ ---
214
+
215
+ ## Sources
216
+
217
+ - [Render API reference](https://api-docs.render.com/reference/introduction) — endpoints, required fields, plan and region enums, read 2026-09-23
218
+ - [Render Postgres Recovery and Backups](https://render.com/docs/postgresql-backups) — free plans have no backups; paid plans get PITR with 3-day (Hobby) / 7-day (Pro) windows
@@ -109,7 +109,9 @@
109
109
  color: var(--ink-faint);
110
110
  font-variant-numeric: tabular-nums; min-width: 3.2em;
111
111
  }
112
- .history__title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
112
+ /* work.js breaks a title after each "/" (a slash-joined run ran 44px past its column
113
+ at 390, task 1004209); `anywhere` is the backstop for a run with no slash at all. */
114
+ .history__title { flex: 1 1 auto; color: var(--ink); min-width: 0; overflow-wrap: anywhere; }
113
115
  /* task 1003828 — the goal a backlog row belongs to, on its own line under the
114
116
  title. The SUB-LINE voice (the .scroll__lede tone), not a tag: a tag would read
115
117
  as one more equal chip beside version/discipline/rank, and where a task LIVES
@@ -1050,7 +1050,7 @@
1050
1050
  return `
1051
1051
  <div class="history__row">
1052
1052
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1053
- <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver}</span>
1053
+ <span class="history__title">${escapeHtml(t.title || '(untitled)').replace(/\//g, '/<wbr>')} ${ver}</span>
1054
1054
  <span class="history__when" title="${escapeHtml(fmtDate(t.shipped_at))}">${escapeHtml(when)}</span>
1055
1055
  </div>
1056
1056
  `;
@@ -1115,7 +1115,7 @@
1115
1115
  return `
1116
1116
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1117
1117
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1118
- <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1118
+ <span class="history__title">${escapeHtml(t.title || '(untitled)').replace(/\//g, '/<wbr>')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1119
1119
  <span class="history__when">${window.OTB.kindIsUnset(t.kind) ? '' : escapeHtml(t.kind)}</span>
1120
1120
  </div>`;
1121
1121
  }).join('');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.994",
3
+ "version": "1.19.996",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.994",
9
+ "version": "1.19.996",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.994",
3
+ "version": "1.19.996",
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",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.994'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.996'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -35,6 +35,7 @@
35
35
  import { test } from 'node:test';
36
36
  import assert from 'node:assert/strict';
37
37
  import { createRequire } from 'node:module';
38
+ import { spawnSync } from 'node:child_process';
38
39
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
39
40
  import { tmpdir } from 'node:os';
40
41
  import path from 'node:path';
@@ -475,3 +476,293 @@ test('both commitPin successes report committed:true', () => {
475
476
  assert.equal(S.commitPin({ dir: '/srv/demo', message: 'm' }, fakeGit({ dirty: [] }).run, () => {}).committed, undefined,
476
477
  'an already-clean tree committed nothing and must not be counted as a heal');
477
478
  });
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // 10. the THIRD bucket — what the lane itself generated (task 1003968)
482
+ //
483
+ // pinState sorted a dirty worktree two ways: "the pin" (ours to commit) and
484
+ // "everything else" (a human's work — halt). An upgrade's OWN output is neither,
485
+ // and every bump produces it: materializeClaude rewrites `.claude/` and
486
+ // regenerateApiArtifacts rewrites the OpenAPI spec + typed client. So on any run
487
+ // that did not commit that output, the lane halted on its own exhaust:
488
+ //
489
+ // • pinMode `leave-dirty` — which commits NOTHING by definition — reached
490
+ // healDecision's `other`-first check on every sweep after its first upgrade
491
+ // and returned `halt` for a mode whose entire premise is that the tree may
492
+ // stay dirty. The `by-design` branch beneath it was unreachable.
493
+ // • the direct engine on a core too old for --pin-no-push, where pinFlagsFor
494
+ // correctly withholds every flag and leaves the same output behind.
495
+ //
496
+ // The fix is a provenance answer, not a wider guard: laneGeneratedPaths asks the
497
+ // instance's OWN core what this upgrade rewrites, pinState sorts by it, and
498
+ // commitPin stages that bucket while its refusal stays keyed on everything else.
499
+ // ---------------------------------------------------------------------------
500
+
501
+ // A resolveFrom/load pair standing in for the target instance's core, so the probe is
502
+ // exercised without a second core on disk.
503
+ function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false } = {}) {
504
+ const seen = [];
505
+ const resolveFrom = (dir, rel) => {
506
+ if (rel === 'scripts/gds/upgrade.js' && api === null) return null;
507
+ if (rel === 'scripts/gds/claude-materialize.js' && willRewrite === null) return null;
508
+ return path.join(dir, 'node_modules', '@bongos', 'core', rel);
509
+ };
510
+ const load = (p) => {
511
+ if (p.endsWith('upgrade.js')) {
512
+ if (upgradeThrows) throw new Error('an older core cannot be loaded here');
513
+ return { API_ARTIFACT_FILES: api || {} };
514
+ }
515
+ if (materializeThrows) throw new Error('materialize blew up');
516
+ return { materializeWillRewrite: willRewrite === undefined ? undefined : (args) => { seen.push(args); return willRewrite; } };
517
+ };
518
+ return { resolveFrom, load, seen };
519
+ }
520
+
521
+ test('laneGeneratedPaths unions the API artifacts with what materialize WOULD rewrite', () => {
522
+ const core = fakeCore({
523
+ api: { 'gen-api-docs.js': ['docs/api/openapi.json'], 'gen-api-client.js': ['clients/bongos-client/index.mjs'] },
524
+ willRewrite: ['.claude/skills/builder-ship/SKILL.md', '.claude/settings.json'],
525
+ });
526
+ assert.deepEqual(S.laneGeneratedPaths('/srv/demo', core).sort(), [
527
+ '.claude/settings.json',
528
+ '.claude/skills/builder-ship/SKILL.md',
529
+ 'clients/bongos-client/index.mjs',
530
+ 'docs/api/openapi.json',
531
+ ]);
532
+ });
533
+
534
+ test('laneGeneratedPaths derives coreRoot from where the module RESOLVED', () => {
535
+ // Not from a package-name guess: an instance may carry the core under any of its
536
+ // published names, and materializeWillRewrite's own default only knows the first.
537
+ const core = fakeCore({ api: {}, willRewrite: [] });
538
+ S.laneGeneratedPaths('/srv/demo', core);
539
+ assert.equal(core.seen.length, 1);
540
+ assert.equal(core.seen[0].instanceDir, '/srv/demo');
541
+ assert.equal(path.resolve(core.seen[0].coreRoot), path.resolve('/srv/demo/node_modules/@bongos/core'));
542
+ });
543
+
544
+ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never a guessed one', () => {
545
+ // The direction matters. An instance this cannot interrogate keeps the OLD, stricter guard
546
+ // (still wedged, and visibly so), rather than gaining a looser one that commits a stranger's work.
547
+ assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: null, willRewrite: null })), []);
548
+ assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: {}, willRewrite: undefined })), []);
549
+ assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ upgradeThrows: true, materializeThrows: true })), []);
550
+ // A core that exports API_ARTIFACT_FILES but whose materialize throws still contributes the
551
+ // half it could answer for.
552
+ assert.deepEqual(
553
+ S.laneGeneratedPaths('/srv/demo', fakeCore({ api: { g: ['docs/api/openapi.json'] }, materializeThrows: true })),
554
+ ['docs/api/openapi.json'],
555
+ );
556
+ });
557
+
558
+ test('the core still exports the two things the probe reads', () => {
559
+ // laneGeneratedPaths reads them BY NAME off a module it resolves at runtime, so a rename in the
560
+ // core degrades it silently to the empty set — the pre-fix wedge, back again and invisible.
561
+ // This is the cross-file contract, pinned here the way GO_LIVE_REQUIRED is.
562
+ const up = require('../scripts/gds/upgrade.js');
563
+ const mat = require('../scripts/gds/claude-materialize.js');
564
+ assert.equal(typeof up.API_ARTIFACT_FILES, 'object', 'upgrade.js no longer exports API_ARTIFACT_FILES');
565
+ assert.ok(Object.values(up.API_ARTIFACT_FILES).flat().includes('docs/api/openapi.json'));
566
+ assert.equal(typeof mat.materializeWillRewrite, 'function', 'claude-materialize.js no longer exports materializeWillRewrite');
567
+ });
568
+
569
+ test('pinState sorts the lane\'s own output into its own bucket, and nothing else with it', () => {
570
+ const g = fakeGit({ dirty: ['package.json', '.claude/settings.json', 'config/branding.json'] });
571
+ const st = S.pinState('/srv/demo', g.run, ['.claude/settings.json']);
572
+ assert.deepEqual(st.pin.map((e) => e.file), ['package.json']);
573
+ assert.deepEqual(st.generated.map((e) => e.file), ['.claude/settings.json']);
574
+ assert.deepEqual(st.other.map((e) => e.file), ['config/branding.json'], 'an owner edit is still a human\'s work');
575
+ });
576
+
577
+ test('pinState called with no generated set behaves exactly as it always did', () => {
578
+ // Every pre-existing caller passes two arguments; the third bucket must be opt-in.
579
+ const g = fakeGit({ dirty: ['package.json', '.claude/settings.json'] });
580
+ const st = S.pinState('/srv/demo', g.run);
581
+ assert.deepEqual(st.generated, []);
582
+ assert.deepEqual(st.other.map((e) => e.file), ['.claude/settings.json']);
583
+ });
584
+
585
+ test('healDecision: leave-dirty finally reaches by-design, because the dirt is the lane\'s own', () => {
586
+ const pre = S.pinState('/srv/demo', fakeGit({ dirty: ['package.json', '.claude/settings.json'] }).run, ['.claude/settings.json']);
587
+ assert.deepEqual(S.healDecision(pre, 'leave-dirty'), { action: 'by-design' });
588
+ assert.deepEqual(S.healDecision(pre, 'local-commit'), { action: 'commit', push: false });
589
+ assert.deepEqual(S.healDecision(pre, 'push'), { action: 'commit', push: true });
590
+ });
591
+
592
+ test('healDecision: an owner\'s edit still halts EVERY mode, leave-dirty included', () => {
593
+ // leave-dirty licenses the LANE's dirt, not a stranger's. That is why the order inside
594
+ // healDecision stays dirt-before-mode rather than being flipped.
595
+ const pre = S.pinState('/srv/demo', fakeGit({ dirty: ['package.json', '.claude/settings.json', 'src/thing.js'] }).run, ['.claude/settings.json']);
596
+ for (const mode of S.PIN_MODES) assert.deepEqual(S.healDecision(pre, mode), { action: 'halt' }, `mode ${mode}`);
597
+ });
598
+
599
+ test('preflightFlagsFor: only leave-dirty gets --force, and it gets nothing else', () => {
600
+ assert.deepEqual(S.preflightFlagsFor('leave-dirty'), ['--force']);
601
+ assert.deepEqual(S.preflightFlagsFor('local-commit'), []);
602
+ assert.deepEqual(S.preflightFlagsFor('push'), []);
603
+ assert.deepEqual(S.preflightFlagsFor(undefined), []);
604
+ });
605
+
606
+ test('preflightFlagsFor has the precedent it claims — go-live.js really does send --force', () => {
607
+ const body = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8')
608
+ .split('function upgradeCommand(')[1].split('\n}')[0];
609
+ assert.match(body, /'--force'/, 'go-live upgradeCommand() no longer sends --force — re-derive preflightFlagsFor from it');
610
+ });
611
+
612
+ test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding inline', () => {
613
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
614
+ assert.match(src, /upArgs\.push\(\.\.\.preflightFlagsFor\(pinMode\)\)/,
615
+ 'the clean-tree policy is a pin-mode decision and must go through the named function');
616
+ });
617
+
618
+ test('every commitPin call site in the lane also decides what the upgrade generated', () => {
619
+ // The sibling of the push assertion in section 8, and for the same reason: a site that inherits
620
+ // the `generated = []` default silently reverts to the two-bucket guard that wedged the lane.
621
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
622
+ const calls = [];
623
+ const re = /commitPin\(\{/g;
624
+ let m;
625
+ while ((m = re.exec(src)) !== null) {
626
+ if (src.slice(Math.max(0, m.index - 9), m.index) === 'function ') continue;
627
+ let depth = 0, end = m.index;
628
+ for (let i = src.indexOf('{', m.index); i < src.length; i++) {
629
+ if (src[i] === '{') depth++;
630
+ else if (src[i] === '}' && --depth === 0) { end = i + 1; break; }
631
+ }
632
+ calls.push(src.slice(m.index, end));
633
+ }
634
+ assert.ok(calls.length >= 3, `expected the three known pin-writing call sites, found ${calls.length}`);
635
+ for (const c of calls) assert.match(c, /generated(: generatedAfter)?,/, `a commitPin call site does not pass the generated set:\n${c}`);
636
+ });
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // 11. real git — the wedge, and the un-wedge
640
+ //
641
+ // The cases above pin each decision on its own; these drive the same three functions
642
+ // against a real repository, because the failure was never inside one decision. It was
643
+ // that all three agreed on a sorting which did not describe what an upgrade leaves
644
+ // behind — and a hand-written `git status` cannot show that.
645
+ // ---------------------------------------------------------------------------
646
+
647
+ function realGit(dir, ...args) { return spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' }); }
648
+ function put(dir, rel, body) {
649
+ const p = path.join(dir, rel);
650
+ mkdirSync(path.dirname(p), { recursive: true });
651
+ writeFileSync(p, body);
652
+ }
653
+
654
+ // A provisioned co-tenant mid-lane: scaffolded + committed, then ONE upgrade applied that moved
655
+ // the pin, re-materialized `.claude/` and regenerated the API artifacts — committing none of it,
656
+ // which is exactly what pinMode leave-dirty asks for.
657
+ function coTenantAfterOneUpgrade() {
658
+ const dir = mkdtempSync(path.join(tmpdir(), 'lane-cotenant-'));
659
+ realGit(dir, 'init', '-q');
660
+ realGit(dir, 'config', 'user.email', 'lane-test@example.invalid');
661
+ realGit(dir, 'config', 'user.name', 'lane test');
662
+ realGit(dir, 'config', 'core.autocrlf', 'false');
663
+ put(dir, 'package.json', JSON.stringify({ dependencies: { '@bongos/core': '1.19.737' } }));
664
+ put(dir, 'package-lock.json', 'lock 1.19.737\n');
665
+ put(dir, '.claude/settings.json', '{"from":"1.19.737"}\n');
666
+ put(dir, '.claude/skills/builder-ship/SKILL.md', '# builder-ship (1.19.737)\n');
667
+ put(dir, 'docs/api/openapi.json', '{"core":"1.19.737"}\n');
668
+ put(dir, 'config/branding.json', '{"identity":{}}\n');
669
+ realGit(dir, 'add', '-A');
670
+ realGit(dir, 'commit', '-q', '-m', 'scaffold');
671
+ // …then one upgrade, with nothing committed after it.
672
+ put(dir, 'package.json', JSON.stringify({ dependencies: { '@bongos/core': '1.19.785' } }));
673
+ put(dir, 'package-lock.json', 'lock 1.19.785\n');
674
+ put(dir, '.claude/settings.json', '{"from":"1.19.785"}\n');
675
+ put(dir, '.claude/skills/builder-ship/SKILL.md', '# builder-ship (1.19.785)\n');
676
+ put(dir, '.claude/skills/new-in-785/SKILL.md', '# a skill this core added\n'); // untracked
677
+ put(dir, 'docs/api/openapi.json', '{"core":"1.19.785"}\n');
678
+ return dir;
679
+ }
680
+
681
+ // What laneGeneratedPaths answers for that upgrade.
682
+ const GENERATED = [
683
+ '.claude/settings.json', '.claude/skills/builder-ship/SKILL.md',
684
+ '.claude/skills/new-in-785/SKILL.md', 'docs/api/openapi.json',
685
+ ];
686
+
687
+ test('real git: the WEDGE — under the old two-way sorting every mode halts, leave-dirty included', () => {
688
+ const dir = coTenantAfterOneUpgrade();
689
+ const pre = S.pinState(dir, spawnSync); // no generated set = the pre-fix behaviour
690
+ assert.ok(pre.other.length, 'the upgrade\'s own output landed in the human-work bucket');
691
+ for (const mode of S.PIN_MODES) {
692
+ assert.deepEqual(S.healDecision(pre, mode), { action: 'halt' }, `mode ${mode} halted here, every sweep, forever`);
693
+ }
694
+ });
695
+
696
+ test('real git: the UN-WEDGE — leave-dirty takes a second sweep with no human hands', () => {
697
+ const dir = coTenantAfterOneUpgrade();
698
+ const pre = S.pinState(dir, spawnSync, GENERATED);
699
+ assert.deepEqual(pre.other, [], 'nothing dirty here is a human\'s work');
700
+ assert.deepEqual(S.healDecision(pre, 'leave-dirty'), { action: 'by-design' });
701
+ // …and the sweep then hands upgrade.js the flag that lets the bump past its own clean-tree
702
+ // pre-flight, which the deliberately-dirty pin would otherwise refuse.
703
+ assert.deepEqual(S.preflightFlagsFor('leave-dirty'), ['--force']);
704
+ });
705
+
706
+ test('real git: local-commit heals the lane\'s own leftovers, tracked and untracked', () => {
707
+ const dir = coTenantAfterOneUpgrade();
708
+ const pre = S.pinState(dir, spawnSync, GENERATED);
709
+ assert.deepEqual(S.healDecision(pre, 'local-commit'), { action: 'commit', push: false });
710
+ const r = S.commitPin({ dir, message: 'heal', push: false, generated: GENERATED }, spawnSync, () => {});
711
+ assert.equal(r.ok, true, r.reason);
712
+ assert.equal(r.action, 'committed-local');
713
+ assert.equal(realGit(dir, 'status', '--porcelain').stdout, '', 'the next sweep finds a clean tree');
714
+ const committed = realGit(dir, 'show', '--name-only', '--format=', 'HEAD').stdout;
715
+ for (const f of ['package.json', 'package-lock.json', ...GENERATED]) {
716
+ assert.ok(committed.includes(f), `${f} rode the pin commit`);
717
+ }
718
+ });
719
+
720
+ test('real git: an OWNER\'s edit is still never swept into the lane\'s commit', () => {
721
+ const dir = coTenantAfterOneUpgrade();
722
+ put(dir, 'config/branding.json', '{"the owner":"was here"}\n'); // tracked
723
+ put(dir, '.claude/skills/my-own-skill/SKILL.md', 'hand-written\n'); // untracked
724
+ const st = S.pinState(dir, spawnSync, GENERATED);
725
+ assert.deepEqual(st.other.map((e) => e.file).sort(), ['.claude/skills/my-own-skill/SKILL.md', 'config/branding.json']);
726
+ const r = S.commitPin({ dir, message: 'heal', push: false, generated: GENERATED }, spawnSync, () => {});
727
+ assert.equal(r.ok, false);
728
+ assert.equal(r.action, 'refused');
729
+ assert.equal(realGit(dir, 'rev-list', '--count', 'HEAD').stdout.trim(), '1', 'nothing was committed at all');
730
+ assert.match(realGit(dir, 'status', '--porcelain').stdout, /config\/branding\.json/, 'and the edit is still theirs');
731
+ });
732
+
733
+ test('real git: a tree holding ONLY the lane\'s own output still commits — the pin need not be dirty', () => {
734
+ // The shape a rollback leaves behind: the pin restored to what is committed, the regenerated
735
+ // artifacts not. Answering `already-clean` here would strand them and wedge the next sweep.
736
+ const dir = coTenantAfterOneUpgrade();
737
+ realGit(dir, 'checkout', '--', 'package.json', 'package-lock.json');
738
+ const st = S.pinState(dir, spawnSync, GENERATED);
739
+ assert.deepEqual(st.pin, []);
740
+ assert.ok(st.generated.length);
741
+ const r = S.commitPin({ dir, message: 'heal', push: false, generated: GENERATED }, spawnSync, () => {});
742
+ assert.equal(r.ok, true, r.reason);
743
+ assert.equal(r.committed, true);
744
+ assert.equal(realGit(dir, 'status', '--porcelain').stdout, '');
745
+ });
746
+
747
+ test('real git: a genuinely clean tree is still a no-op', () => {
748
+ const dir = coTenantAfterOneUpgrade();
749
+ realGit(dir, 'add', '-A'); realGit(dir, 'commit', '-q', '-m', 'by hand');
750
+ const r = S.commitPin({ dir, message: 'heal', push: false, generated: GENERATED }, spawnSync, () => {});
751
+ assert.equal(r.action, 'already-clean');
752
+ assert.equal(r.committed, undefined);
753
+ });
754
+
755
+ test('the post-upgrade net re-probes, rather than reusing the set from before the bump', () => {
756
+ // A skill the NEW core adds is absent from the pre-upgrade answer, so reusing it would drop that
757
+ // file into `other` and wedge the lane one release later — this defect again, wearing the next
758
+ // version number. The heal, which runs BEFORE the bump, must keep using the pre-bump set.
759
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
760
+ const afterSpawn = src.split('const generatedAfter = laneGeneratedPaths(inst.dir);')[1];
761
+ assert.ok(afterSpawn, 'the post-upgrade re-probe is gone');
762
+ assert.equal((afterSpawn.match(/generated: generatedAfter,/g) || []).length, 2,
763
+ 'both post-upgrade commitPin sites (the rollback tidy and the durability net) must use the re-probed set');
764
+ assert.match(afterSpawn, /pinState\(inst\.dir, spawnSync, generatedAfter\)/);
765
+ const beforeSpawn = src.split('const generatedAfter = laneGeneratedPaths(inst.dir);')[0];
766
+ assert.match(beforeSpawn, /const pre = pinState\(inst\.dir, spawnSync, generated\);/,
767
+ 'the heal runs before the bump, so it reads the core installed THEN');
768
+ });
@@ -0,0 +1,38 @@
1
+ // tests/work_title_wrap.mjs — a Work board title wraps inside its column (task 1004209).
2
+ //
3
+ // At 390 the backlog title "Watch redesign: … People/Operations/Security tabs" ran
4
+ // 44px past its column: a slash-joined run offers the browser no break point. The
5
+ // rendered check (scripts/gds/render-check.js) is what measures it; this pins the
6
+ // two halves of the fix in source so neither is dropped by an unrelated edit.
7
+ //
8
+ // Run: node tests/work_title_wrap.mjs
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { makeRunner } from './helpers.mjs';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
17
+ const WORK_SRC = read('modules/hall-ui/public/work.js');
18
+ const WORK_CSS = read('modules/hall-ui/public/work.css');
19
+
20
+ const { test, summary } = makeRunner();
21
+
22
+ await test('every history title breaks after a slash, and only after escaping', () => {
23
+ const sites = WORK_SRC.match(/<span class="history__title">[^\n]*/g) || [];
24
+ assert.equal(sites.length, 2, 'the backlog and completed rows both render a history title');
25
+ for (const s of sites) {
26
+ assert.ok(s.includes("escapeHtml(t.title || '(untitled)').replace(/\\//g, '/<wbr>')"),
27
+ `the <wbr> goes in AFTER escapeHtml, so a title cannot inject markup: ${s.trim()}`);
28
+ }
29
+ });
30
+
31
+ await test('the break-anything backstop stays on .history__title', () => {
32
+ const rule = WORK_CSS.match(/^\.history__title \{[^}]*\}/m);
33
+ assert.ok(rule, '.history__title rule present');
34
+ assert.match(rule[0], /overflow-wrap:\s*anywhere/, 'a run with no slash still wraps instead of running out of the column');
35
+ assert.match(rule[0], /min-width:\s*0/, 'a flex item needs min-width: 0 to shrink below its longest word');
36
+ });
37
+
38
+ summary();